diff --git a/CHANGELOG.md b/CHANGELOG.md
index a8ee45024..2f66582d9 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,52 @@
Certbot adheres to [Semantic Versioning](http://semver.org/).
+## 0.14.1 - 2017-05-16
+
+### Fixed
+
+* Certbot now works with configargparse 0.12.0.
+* Issues with the Apache plugin and Augeas 1.7+ have been resolved.
+* A problem where the Nginx plugin would fail to install certificates on
+systems that had the plugin's SSL/TLS options file from 7+ months ago has been
+fixed.
+
+More details about these changes can be found on our GitHub repo:
+https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.14.1+is%3Aclosed
+
+## 0.14.0 - 2017-05-04
+
+### Added
+
+* Python 3.3+ support for all Certbot packages. `certbot-auto` still currently
+only supports Python 2, but the `acme`, `certbot`, `certbot-apache`, and
+`certbot-nginx` packages on PyPI now fully support Python 2.6, 2.7, and 3.3+.
+* Certbot's Apache plugin now handles multiple virtual hosts per file.
+* Lockfiles to prevent multiple versions of Certbot running simultaneously.
+
+### Changed
+
+* When converting an HTTP virtual host to HTTPS in Apache, Certbot only copies
+the virtual host rather than the entire contents of the file it's contained
+in.
+* The Nginx plugin now includes SSL/TLS directives in a separate file located
+in Certbot's configuration directory rather than copying the contents of the
+file into every modified `server` block.
+
+### Fixed
+
+* Ensure logging is configured before parts of Certbot attempt to log any
+messages.
+* Support for the `--quiet` flag in `certbot-auto`.
+* Reverted a change made in a previous release to make the `acme` and `certbot`
+packages always depend on `argparse`. This dependency is conditional again on
+the user's Python version.
+* Small bugs in the Nginx plugin such as properly handling empty `server`
+blocks and setting `server_names_hash_bucket_size` during challenges.
+
+As always, a more complete list of changes can be found on GitHub:
+https://github.com/certbot/certbot/issues?q=is%3Aissue+milestone%3A0.14.0+is%3Aclosed
+
## 0.13.0 - 2017-04-06
### Added
diff --git a/Dockerfile-dev b/Dockerfile-dev
index 607aa3441..581b58f11 100644
--- a/Dockerfile-dev
+++ b/Dockerfile-dev
@@ -23,7 +23,7 @@ WORKDIR /opt/certbot/src
# TODO: Install Apache/Nginx for plugin development.
COPY letsencrypt-auto-source/letsencrypt-auto /opt/certbot/src/letsencrypt-auto-source/letsencrypt-auto
RUN /opt/certbot/src/letsencrypt-auto-source/letsencrypt-auto --os-packages-only && \
- apt-get install python3-dev -y && \
+ apt-get install python3-dev git -y && \
apt-get clean && \
rm -rf /var/lib/apt/lists/* \
/tmp/* \
diff --git a/README.rst b/README.rst
index c51168216..ff6fafe36 100644
--- a/README.rst
+++ b/README.rst
@@ -96,18 +96,10 @@ ACME spec: http://ietf-wg-acme.github.io/acme/
ACME working area in github: https://github.com/ietf-wg-acme/acme
-
-Mailing list: `client-dev`_ (to subscribe without a Google account, send an
-email to client-dev+subscribe@letsencrypt.org)
-
|build-status| |coverage| |docs| |container|
.. _Freenode: https://webchat.freenode.net?channels=%23letsencrypt
-.. _OFTC: https://webchat.oftc.net?channels=%23certbot
-
-.. _client-dev: https://groups.google.com/a/letsencrypt.org/forum/#!forum/client-dev
-
.. |build-status| image:: https://travis-ci.org/certbot/certbot.svg?branch=master
:target: https://travis-ci.org/certbot/certbot
:alt: Travis CI status
@@ -141,7 +133,7 @@ Current Features
* Supports multiple web servers:
- apache/2.x (beta support for auto-configuration)
- - nginx/0.8.48+ (alpha support for auto-configuration)
+ - nginx/0.8.48+ (alpha support for auto-configuration, beta support in 0.14.0)
- webroot (adds files to webroot directories in order to prove control of
domains and obtain certs)
- standalone (runs its own simple webserver to prove you control a domain)
@@ -157,7 +149,7 @@ Current Features
runs https only (Apache only)
* Fully automated.
* Configuration changes are logged and can be reverted.
-* Supports ncurses and text (-t) UI, or can be driven entirely from the
+* Supports an interactive text UI, or can be driven entirely from the
command line.
* Free and Open Source Software, made with Python.
diff --git a/acme/acme/client.py b/acme/acme/client.py
index a069876d5..9455159de 100644
--- a/acme/acme/client.py
+++ b/acme/acme/client.py
@@ -564,6 +564,9 @@ class ClientNetwork(object): # pylint: disable=too-many-instance-attributes
except ValueError:
jobj = None
+ if response.status_code == 409:
+ raise errors.ConflictError(response.headers.get('Location'))
+
if not response.ok:
if jobj is not None:
if response_ct != cls.JSON_ERROR_CONTENT_TYPE:
diff --git a/acme/acme/client_test.py b/acme/acme/client_test.py
index 09bb38c00..cd1a90645 100644
--- a/acme/acme/client_test.py
+++ b/acme/acme/client_test.py
@@ -513,6 +513,12 @@ class ClientNetworkTest(unittest.TestCase):
self.assertEqual(
self.response, self.net._check_response(self.response))
+ def test_check_response_conflict(self):
+ self.response.ok = False
+ self.response.status_code = 409
+ # pylint: disable=protected-access
+ self.assertRaises(errors.ConflictError, self.net._check_response, self.response)
+
def test_check_response_jobj(self):
self.response.json.return_value = {}
for response_ct in [self.net.JSON_CONTENT_TYPE, 'foo']:
diff --git a/acme/acme/errors.py b/acme/acme/errors.py
index 7446b60fc..9d991fd75 100644
--- a/acme/acme/errors.py
+++ b/acme/acme/errors.py
@@ -82,3 +82,14 @@ class PollError(ClientError):
def __repr__(self):
return '{0}(exhausted={1!r}, updated={2!r})'.format(
self.__class__.__name__, self.exhausted, self.updated)
+
+class ConflictError(ClientError):
+ """Error for when the server returns a 409 (Conflict) HTTP status.
+
+ In the version of ACME implemented by Boulder, this is used to find an
+ account if you only have the private key, but don't know the account URL.
+ """
+ def __init__(self, location):
+ self.location = location
+ super(ConflictError, self).__init__()
+
diff --git a/acme/acme/jose/jws.py b/acme/acme/jose/jws.py
index 8fa8d7670..5f446e4b1 100644
--- a/acme/acme/jose/jws.py
+++ b/acme/acme/jose/jws.py
@@ -222,7 +222,8 @@ class Signature(json_util.JSONObjectWithFields):
protected_params = {}
for header in protect:
- protected_params[header] = header_params.pop(header)
+ if header in header_params:
+ protected_params[header] = header_params.pop(header)
if protected_params:
# pylint: disable=star-args
protected = cls.header_cls(**protected_params).json_dumps()
diff --git a/acme/acme/jws.py b/acme/acme/jws.py
index 79e96edcb..f9b81749a 100644
--- a/acme/acme/jws.py
+++ b/acme/acme/jws.py
@@ -49,6 +49,6 @@ class JWS(jose.JWS):
# jwk field if kid is not provided.
include_jwk = kid is None
return super(JWS, cls).sign(payload, key=key, alg=alg,
- protect=frozenset(['nonce', 'url', 'kid']),
+ protect=frozenset(['nonce', 'url', 'kid', 'jwk', 'alg']),
nonce=nonce, url=url, kid=kid,
include_jwk=include_jwk)
diff --git a/acme/setup.py b/acme/setup.py
index a640ae6bb..0938c5c68 100644
--- a/acme/setup.py
+++ b/acme/setup.py
@@ -4,11 +4,10 @@ from setuptools import setup
from setuptools import find_packages
-version = '0.14.0.dev0'
+version = '0.15.0.dev0'
# Please update tox.ini when modifying dependency version requirements
install_requires = [
- 'argparse',
# load_pem_private/public_key (>=0.6)
# rsa_recover_prime_factors (>=0.8)
'cryptography>=0.8',
@@ -28,6 +27,13 @@ install_requires = [
'six',
]
+# env markers cause problems with older pip and setuptools
+if sys.version_info < (2, 7):
+ install_requires.extend([
+ 'argparse',
+ 'ordereddict',
+ ])
+
dev_extras = [
'nose',
'tox',
@@ -59,6 +65,7 @@ setup(
'Programming Language :: Python :: 3.3',
'Programming Language :: Python :: 3.4',
'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Security',
],
diff --git a/certbot-apache/certbot_apache/augeas_configurator.py b/certbot-apache/certbot_apache/augeas_configurator.py
index 3735284ef..444fbb763 100644
--- a/certbot-apache/certbot_apache/augeas_configurator.py
+++ b/certbot-apache/certbot_apache/augeas_configurator.py
@@ -47,7 +47,9 @@ class AugeasConfigurator(common.Plugin):
loadpath=constants.AUGEAS_LENS_DIR,
# Do not save backup (we do it ourselves), do not load
# anything by default
- flags=(augeas.Augeas.NONE | augeas.Augeas.NO_MODL_AUTOLOAD))
+ flags=(augeas.Augeas.NONE |
+ augeas.Augeas.NO_MODL_AUTOLOAD |
+ augeas.Augeas.ENABLE_SPAN))
self.recovery_routine()
def check_parsing_errors(self, lens):
@@ -118,8 +120,8 @@ class AugeasConfigurator(common.Plugin):
# If the augeas tree didn't change, no files were saved and a backup
# should not be created
+ save_files = set()
if save_paths:
- save_files = set()
for path in save_paths:
save_files.add(self.aug.get(path)[6:])
@@ -138,6 +140,12 @@ class AugeasConfigurator(common.Plugin):
self.save_notes = ""
self.aug.save()
+ # Force reload if files were modified
+ # This is needed to recalculate augeas directive span
+ if save_files:
+ for sf in save_files:
+ self.aug.remove("/files/"+sf)
+ self.aug.load()
if title and not temporary:
try:
self.reverter.finalize_checkpoint(title)
diff --git a/certbot-apache/certbot_apache/configurator.py b/certbot-apache/certbot_apache/configurator.py
index 39d25619d..13b325d7f 100644
--- a/certbot-apache/certbot_apache/configurator.py
+++ b/certbot-apache/certbot_apache/configurator.py
@@ -197,6 +197,14 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
install_ssl_options_conf(self.mod_ssl_conf)
+ # Prevent two Apache plugins from modifying a config at once
+ try:
+ util.lock_dir_until_exit(self.conf("server-root"))
+ except (OSError, errors.LockError):
+ logger.debug("Encountered error:", exc_info=True)
+ raise errors.PluginError(
+ "Unable to lock %s", self.conf("server-root"))
+
def _check_aug_version(self):
""" Checks that we have recent enough version of libaugeas.
If augeas version is recent enough, it will support case insensitive
@@ -494,6 +502,32 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return ""
+ def _get_vhost_names(self, path):
+ """Helper method for getting the ServerName and
+ ServerAlias values from vhost in path
+
+ :param path: Path to read ServerName and ServerAliases from
+
+ :returns: Tuple including ServerName and `list` of ServerAlias strings
+ """
+
+ servername_match = self.parser.find_dir(
+ "ServerName", None, start=path, exclude=False)
+ serveralias_match = self.parser.find_dir(
+ "ServerAlias", None, start=path, exclude=False)
+
+ serveraliases = []
+ for alias in serveralias_match:
+ serveralias = self.parser.get_arg(alias)
+ serveraliases.append(serveralias)
+
+ servername = None
+ if servername_match:
+ # Get last ServerName as each overwrites the previous
+ servername = self.parser.get_arg(servername_match[-1])
+
+ return (servername, serveraliases)
+
def _add_servernames(self, host):
"""Helper function for get_virtual_hosts().
@@ -501,22 +535,15 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
:type host: :class:`~certbot_apache.obj.VirtualHost`
"""
- # Take the final ServerName as each overrides the previous
- servername_match = self.parser.find_dir(
- "ServerName", None, start=host.path, exclude=False)
- serveralias_match = self.parser.find_dir(
- "ServerAlias", None, start=host.path, exclude=False)
- for alias in serveralias_match:
- serveralias = self.parser.get_arg(alias)
- if not host.modmacro:
- host.aliases.add(serveralias)
+ servername, serveraliases = self._get_vhost_names(host.path)
- if servername_match:
- # Get last ServerName as each overwrites the previous
- servername = self.parser.get_arg(servername_match[-1])
+ for alias in serveraliases:
if not host.modmacro:
- host.name = servername
+ host.aliases.add(alias)
+
+ if not host.modmacro:
+ host.name = servername
def _create_vhost(self, path):
"""Used by get_virtual_hosts to create vhost objects
@@ -573,30 +600,48 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
# Search base config, and all included paths for VirtualHosts
+ file_paths = {}
+ internal_paths = defaultdict(set)
vhs = []
- vhost_paths = {}
- for vhost_path in self.parser.parser_paths.keys():
+ # Make a list of parser paths because the parser_paths
+ # dictionary may be modified during the loop.
+ for vhost_path in list(self.parser.parser_paths):
paths = self.aug.match(
("/files%s//*[label()=~regexp('%s')]" %
(vhost_path, parser.case_i("VirtualHost"))))
paths = [path for path in paths if
- os.path.basename(path.lower()) == "virtualhost"]
+ "virtualhost" in os.path.basename(path).lower()]
for path in paths:
new_vhost = self._create_vhost(path)
if not new_vhost:
continue
+ internal_path = get_internal_aug_path(new_vhost.path)
realpath = os.path.realpath(new_vhost.filep)
- if realpath not in vhost_paths.keys():
+ if realpath not in file_paths:
+ file_paths[realpath] = new_vhost.filep
+ internal_paths[realpath].add(internal_path)
vhs.append(new_vhost)
- vhost_paths[realpath] = new_vhost.filep
- elif realpath == new_vhost.filep:
+ elif (realpath == new_vhost.filep and
+ realpath != file_paths[realpath]):
# Prefer "real" vhost paths instead of symlinked ones
# ex: sites-enabled/vh.conf -> sites-available/vh.conf
# remove old (most likely) symlinked one
- vhs = [v for v in vhs if v.filep != vhost_paths[realpath]]
+ new_vhs = []
+ for v in vhs:
+ if v.filep == file_paths[realpath]:
+ internal_paths[realpath].remove(
+ get_internal_aug_path(v.path))
+ else:
+ new_vhs.append(v)
+ vhs = new_vhs
+
+ file_paths[realpath] = realpath
+ internal_paths[realpath].add(internal_path)
+ vhs.append(new_vhost)
+ elif internal_path not in internal_paths[realpath]:
+ internal_paths[realpath].add(internal_path)
vhs.append(new_vhost)
- vhost_paths[realpath] = realpath
return vhs
@@ -792,24 +837,27 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
avail_fp = nonssl_vhost.filep
ssl_fp = self._get_ssl_vhost_path(avail_fp)
- self._copy_create_ssl_vhost_skeleton(avail_fp, ssl_fp)
+ orig_matches = self.aug.match("/files%s//* [label()=~regexp('%s')]" %
+ (self._escape(ssl_fp),
+ parser.case_i("VirtualHost")))
+
+ self._copy_create_ssl_vhost_skeleton(nonssl_vhost, ssl_fp)
# Reload augeas to take into account the new vhost
self.aug.load()
# Get Vhost augeas path for new vhost
- vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" %
- (self._escape(ssl_fp), parser.case_i("VirtualHost")))
- if len(vh_p) != 1:
- logger.error("Error: should only be one vhost in %s", avail_fp)
- raise errors.PluginError("Currently, we only support "
- "configurations with one vhost per file")
- else:
- # This simplifies the process
- vh_p = vh_p[0]
+ new_matches = self.aug.match("/files%s//* [label()=~regexp('%s')]" %
+ (self._escape(ssl_fp),
+ parser.case_i("VirtualHost")))
+
+ vh_p = self._get_new_vh_path(orig_matches, new_matches)
+
+ if not vh_p:
+ raise errors.PluginError(
+ "Could not reverse map the HTTPS VirtualHost to the original")
# Update Addresses
self._update_ssl_vhosts_addrs(vh_p)
-
# Add directives
self._add_dummy_ssl_directives(vh_p)
self.save()
@@ -822,6 +870,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# We know the length is one because of the assertion above
# Create the Vhost object
ssl_vhost = self._create_vhost(vh_p)
+ ssl_vhost.ancestor = nonssl_vhost
self.vhosts.append(ssl_vhost)
# NOTE: Searches through Augeas seem to ruin changes to directives
@@ -835,6 +884,20 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
return ssl_vhost
+ def _get_new_vh_path(self, orig_matches, new_matches):
+ """ Helper method for make_vhost_ssl for matching augeas paths. Returns
+ VirtualHost path from new_matches that's not present in orig_matches.
+
+ Paths are normalized, because augeas leaves indices out for paths
+ with only single directive with a similar key """
+
+ orig_matches = [i.replace("[1]", "") for i in orig_matches]
+ for match in new_matches:
+ if match.replace("[1]", "") not in orig_matches:
+ # Return the unmodified path
+ return match
+ return None
+
def _get_ssl_vhost_path(self, non_ssl_vh_fp):
# Get filepath of new ssl_vhost
if non_ssl_vh_fp.endswith(".conf"):
@@ -859,7 +922,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
:rtype: bool
"""
- if not line.lstrip().startswith("RewriteRule"):
+ if not line.lower().lstrip().startswith("rewriterule"):
return False
# According to: http://httpd.apache.org/docs/2.4/rewrite/flags.html
@@ -875,10 +938,10 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
# Sift line if it redirects the request to a HTTPS site
return target.startswith("https://")
- def _copy_create_ssl_vhost_skeleton(self, avail_fp, ssl_fp):
+ def _copy_create_ssl_vhost_skeleton(self, vhost, ssl_fp):
"""Copies over existing Vhost with IfModule mod_ssl.c> skeleton.
- :param str avail_fp: Pointer to the original available non-ssl vhost
+ :param obj.VirtualHost vhost: Original VirtualHost object
:param str ssl_fp: Full path where the new ssl_vhost will reside.
A new file is created on the filesystem.
@@ -886,70 +949,25 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
"""
# First register the creation so that it is properly removed if
# configuration is rolled back
- self.reverter.register_file_creation(False, ssl_fp)
+ if os.path.exists(ssl_fp):
+ notes = "Appended new VirtualHost directive to file %s" % ssl_fp
+ files = set()
+ files.add(ssl_fp)
+ self.reverter.add_to_checkpoint(files, notes)
+ else:
+ self.reverter.register_file_creation(False, ssl_fp)
sift = False
try:
- with open(avail_fp, "r") as orig_file:
- with open(ssl_fp, "w") as new_file:
- new_file.write("\n")
+ orig_contents = self._get_vhost_block(vhost)
+ ssl_vh_contents, sift = self._sift_rewrite_rules(orig_contents)
- comment = ("# Some rewrite rules in this file were "
- "disabled on your HTTPS site,\n"
- "# because they have the potential to create "
- "redirection loops.\n")
-
- for line in orig_file:
- A = line.lstrip().startswith("RewriteCond")
- B = line.lstrip().startswith("RewriteRule")
-
- if not (A or B):
- new_file.write(line)
- continue
-
- # A RewriteRule that doesn't need filtering
- if B and not self._sift_rewrite_rule(line):
- new_file.write(line)
- continue
-
- # A RewriteRule that does need filtering
- if B and self._sift_rewrite_rule(line):
- if not sift:
- new_file.write(comment)
- sift = True
- new_file.write("# " + line)
- continue
-
- # We save RewriteCond(s) and their corresponding
- # RewriteRule in 'chunk'.
- # We then decide whether we comment out the entire
- # chunk based on its RewriteRule.
- chunk = []
- if A:
- chunk.append(line)
- line = next(orig_file)
-
- # RewriteCond(s) must be followed by one RewriteRule
- while not line.lstrip().startswith("RewriteRule"):
- chunk.append(line)
- line = next(orig_file)
-
- # Now, current line must start with a RewriteRule
- chunk.append(line)
-
- if self._sift_rewrite_rule(line):
- if not sift:
- new_file.write(comment)
- sift = True
-
- new_file.write(''.join(
- ['# ' + l for l in chunk]))
- continue
- else:
- new_file.write(''.join(chunk))
- continue
-
- new_file.write("\n")
+ with open(ssl_fp, "a") as new_file:
+ new_file.write("\n")
+ new_file.write("\n".join(ssl_vh_contents))
+ # The content does not include the closing tag, so add it
+ new_file.write("\n")
+ new_file.write("\n")
except IOError:
logger.fatal("Error writing/reading to file in make_vhost_ssl")
raise errors.PluginError("Unable to write/read in make_vhost_ssl")
@@ -959,9 +977,116 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
reporter.add_message(
"Some rewrite rules copied from {0} were disabled in the "
"vhost for your HTTPS site located at {1} because they have "
- "the potential to create redirection loops.".format(avail_fp,
- ssl_fp),
- reporter.MEDIUM_PRIORITY)
+ "the potential to create redirection loops.".format(
+ vhost.filep, ssl_fp), reporter.MEDIUM_PRIORITY)
+ self.aug.set("/augeas/files%s/mtime" % (self._escape(ssl_fp)), "0")
+ self.aug.set("/augeas/files%s/mtime" % (self._escape(vhost.filep)), "0")
+
+ def _sift_rewrite_rules(self, contents):
+ """ Helper function for _copy_create_ssl_vhost_skeleton to prepare the
+ new HTTPS VirtualHost contents. Currently disabling the rewrites """
+
+ result = []
+ sift = False
+ contents = iter(contents)
+
+ comment = ("# Some rewrite rules in this file were "
+ "disabled on your HTTPS site,\n"
+ "# because they have the potential to create "
+ "redirection loops.\n")
+
+ for line in contents:
+ A = line.lower().lstrip().startswith("rewritecond")
+ B = line.lower().lstrip().startswith("rewriterule")
+
+ if not (A or B):
+ result.append(line)
+ continue
+
+ # A RewriteRule that doesn't need filtering
+ if B and not self._sift_rewrite_rule(line):
+ result.append(line)
+ continue
+
+ # A RewriteRule that does need filtering
+ if B and self._sift_rewrite_rule(line):
+ if not sift:
+ result.append(comment)
+ sift = True
+ result.append("# " + line)
+ continue
+
+ # We save RewriteCond(s) and their corresponding
+ # RewriteRule in 'chunk'.
+ # We then decide whether we comment out the entire
+ # chunk based on its RewriteRule.
+ chunk = []
+ if A:
+ chunk.append(line)
+ line = next(contents)
+
+ # RewriteCond(s) must be followed by one RewriteRule
+ while not line.lower().lstrip().startswith("rewriterule"):
+ chunk.append(line)
+ line = next(contents)
+
+ # Now, current line must start with a RewriteRule
+ chunk.append(line)
+
+ if self._sift_rewrite_rule(line):
+ if not sift:
+ result.append(comment)
+ sift = True
+
+ result.append('\n'.join(
+ ['# ' + l for l in chunk]))
+ continue
+ else:
+ result.append('\n'.join(chunk))
+ continue
+ return result, sift
+
+ def _get_vhost_block(self, vhost):
+ """ Helper method to get VirtualHost contents from the original file.
+ This is done with help of augeas span, which returns the span start and
+ end positions
+
+ :returns: `list` of VirtualHost block content lines without closing tag
+ """
+
+ try:
+ span_val = self.aug.span(vhost.path)
+ except ValueError:
+ logger.fatal("Error while reading the VirtualHost %s from "
+ "file %s", vhost.name, vhost.filep, exc_info=True)
+ raise errors.PluginError("Unable to read VirtualHost from file")
+ span_filep = span_val[0]
+ span_start = span_val[5]
+ span_end = span_val[6]
+ with open(span_filep, 'r') as fh:
+ fh.seek(span_start)
+ vh_contents = fh.read(span_end-span_start).split("\n")
+ self._remove_closing_vhost_tag(vh_contents)
+ return vh_contents
+
+ def _remove_closing_vhost_tag(self, vh_contents):
+ """Removes the closing VirtualHost tag if it exists.
+
+ This method modifies vh_contents directly to remove the closing
+ tag. If the closing vhost tag is found, everything on the line
+ after it is also removed. Whether or not this tag is included
+ in the result of span depends on the Augeas version.
+
+ :param list vh_contents: VirtualHost block contents to check
+
+ """
+ for offset, line in enumerate(reversed(vh_contents)):
+ if line:
+ line_index = line.lower().find("")
+ if line_index != -1:
+ content_index = len(vh_contents) - offset - 1
+ vh_contents[content_index] = line[:line_index]
+ break
def _update_ssl_vhosts_addrs(self, vh_path):
ssl_addrs = set()
@@ -1008,16 +1133,9 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
self.parser.add_dir(vh_path, "Include", self.mod_ssl_conf)
def _add_servername_alias(self, target_name, vhost):
- fp = self._escape(vhost.filep)
- vh_p = self.aug.match("/files%s//* [label()=~regexp('%s')]" %
- (fp, parser.case_i("VirtualHost")))
- if not vh_p:
- return
- vh_path = vh_p[0]
- if (self.parser.find_dir("ServerName", target_name,
- start=vh_path, exclude=False) or
- self.parser.find_dir("ServerAlias", target_name,
- start=vh_path, exclude=False)):
+ vh_path = vhost.path
+ sname, saliases = self._get_vhost_names(vh_path)
+ if target_name == sname or target_name in saliases:
return
if self._has_matching_wildcard(vh_path, target_name):
return
@@ -1417,7 +1535,7 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
for re_path in rewrite_engine_path_list:
# A RewriteEngine directive may also be included in per
# directory .htaccess files. We only care about the VirtualHost.
- if 'VirtualHost' in re_path:
+ if 'virtualhost' in re_path.lower():
return self.parser.get_arg(re_path)
return False
@@ -1508,6 +1626,8 @@ class ApacheConfigurator(augeas_configurator.AugeasConfigurator):
def _get_http_vhost(self, ssl_vhost):
"""Find appropriate HTTP vhost for ssl_vhost."""
# First candidate vhosts filter
+ if ssl_vhost.ancestor:
+ return ssl_vhost.ancestor
candidate_http_vhs = [
vhost for vhost in self.vhosts if not vhost.ssl
]
@@ -1819,25 +1939,46 @@ def get_file_path(vhost_path):
:rtype: str
"""
- # Strip off /files/
- try:
- if vhost_path.startswith("/files/"):
- avail_fp = vhost_path[7:].split("/")
- else:
- return None
- except AttributeError:
- # If we received a None path
+ if not vhost_path or not vhost_path.startswith("/files/"):
return None
- last_good = ""
- # Loop through the path parts and validate after every addition
- for p in avail_fp:
- cur_path = last_good+"/"+p
- if os.path.exists(cur_path):
- last_good = cur_path
- else:
- break
- return last_good
+ return _split_aug_path(vhost_path)[0]
+
+
+def get_internal_aug_path(vhost_path):
+ """Get the Augeas path for a vhost with the file path removed.
+
+ :param str vhost_path: Augeas virtual host path
+
+ :returns: Augeas path to vhost relative to the containing file
+ :rtype: str
+
+ """
+ return _split_aug_path(vhost_path)[1]
+
+
+def _split_aug_path(vhost_path):
+ """Splits an Augeas path into a file path and an internal path.
+
+ After removing "/files", this function splits vhost_path into the
+ file path and the remaining Augeas path.
+
+ :param str vhost_path: Augeas virtual host path
+
+ :returns: file path and internal Augeas path
+ :rtype: `tuple` of `str`
+
+ """
+ # Strip off /files
+ file_path = vhost_path[6:]
+ internal_path = []
+
+ # Remove components from the end of file_path until it becomes valid
+ while not os.path.exists(file_path):
+ file_path, _, internal_path_part = file_path.rpartition("/")
+ internal_path.append(internal_path_part)
+
+ return file_path, "/".join(reversed(internal_path))
def install_ssl_options_conf(options_ssl):
diff --git a/certbot-apache/certbot_apache/display_ops.py b/certbot-apache/certbot_apache/display_ops.py
index 22aafc0fe..6bcb64dd5 100644
--- a/certbot-apache/certbot_apache/display_ops.py
+++ b/certbot-apache/certbot_apache/display_ops.py
@@ -91,7 +91,7 @@ def _vhost_menu(domain, vhosts):
msg = ("Encountered vhost ambiguity but unable to ask for user guidance in "
"non-interactive mode. Currently Certbot needs each vhost to be "
"in its own conf file, and may need vhosts to be explicitly "
- "labelled with ServerName or ServerAlias directories.")
+ "labelled with ServerName or ServerAlias directives.")
logger.warning(msg)
raise errors.MissingCommandlineFlag(msg)
diff --git a/certbot-apache/certbot_apache/obj.py b/certbot-apache/certbot_apache/obj.py
index 30cb24844..1e3579858 100644
--- a/certbot-apache/certbot_apache/obj.py
+++ b/certbot-apache/certbot_apache/obj.py
@@ -112,6 +112,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
+ :ivar VirtualHost ancestor: A non-SSL VirtualHost this is based on
https://httpd.apache.org/docs/2.4/vhosts/details.html
@@ -123,7 +124,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
strip_name = re.compile(r"^(?:.+://)?([^ :$]*)")
def __init__(self, filep, path, addrs, ssl, enabled, name=None,
- aliases=None, modmacro=False):
+ aliases=None, modmacro=False, ancestor=None):
# pylint: disable=too-many-arguments
"""Initialize a VH."""
@@ -135,6 +136,7 @@ class VirtualHost(object): # pylint: disable=too-few-public-methods
self.ssl = ssl
self.enabled = enabled
self.modmacro = modmacro
+ self.ancestor = ancestor
def get_names(self):
"""Return a set of all names."""
diff --git a/certbot-apache/certbot_apache/tests/configurator_test.py b/certbot-apache/certbot_apache/tests/configurator_test.py
index 45e701bd5..75589dce5 100644
--- a/certbot-apache/certbot_apache/tests/configurator_test.py
+++ b/certbot-apache/certbot_apache/tests/configurator_test.py
@@ -95,6 +95,23 @@ class MultipleVhostsTest(util.ApacheTest):
self.assertRaises(
errors.NotSupportedError, self.config.prepare)
+ def test_prepare_locked(self):
+ server_root = self.config.conf("server-root")
+ self.config.config_test = mock.Mock()
+ os.remove(os.path.join(server_root, ".certbot.lock"))
+ certbot_util.lock_and_call(self._test_prepare_locked, server_root)
+
+ @mock.patch("certbot_apache.parser.ApacheParser")
+ @mock.patch("certbot_apache.configurator.util.exe_exists")
+ def _test_prepare_locked(self, unused_parser, unused_exe_exists):
+ try:
+ self.config.prepare()
+ except errors.PluginError as err:
+ err_msg = str(err)
+ self.assertTrue("lock" in err_msg)
+ self.assertTrue(self.config.conf("server-root") in err_msg)
+ else: # pragma: no cover
+ self.fail("Exception wasn't raised!")
def test_add_parser_arguments(self): # pylint: disable=no-self-use
from certbot_apache.configurator import ApacheConfigurator
@@ -138,6 +155,17 @@ class MultipleVhostsTest(util.ApacheTest):
self.assertEqual(get_file_path("nonexistent"), None)
self.assertEqual(self.config._create_vhost("nonexistent"), None) # pylint: disable=protected-access
+ def test_get_aug_internal_path(self):
+ from certbot_apache.configurator import get_internal_aug_path
+ internal_paths = [
+ "VirtualHost", "IfModule/VirtualHost", "VirtualHost", "VirtualHost",
+ "Macro/VirtualHost", "IfModule/VirtualHost", "VirtualHost",
+ "IfModule/VirtualHost"]
+
+ for i, internal_path in enumerate(internal_paths):
+ self.assertEqual(
+ get_internal_aug_path(self.vh_truth[i].path), internal_path)
+
def test_bad_servername_alias(self):
ssl_vh1 = obj.VirtualHost(
"fp1", "ap1", set([obj.Addr(("*", "443"))]),
@@ -569,6 +597,22 @@ class MultipleVhostsTest(util.ApacheTest):
# already listens to the correct port
self.assertEqual(mock_add_dir.call_count, 0)
+ def test_make_vhost_ssl_with_mock_span(self):
+ # span excludes the closing tag in older versions
+ # of Augeas
+ return_value = [self.vh_truth[0].filep, 1, 12, 0, 0, 0, 1142]
+ with mock.patch.object(self.config.aug, 'span') as mock_span:
+ mock_span.return_value = return_value
+ self.test_make_vhost_ssl()
+
+ def test_make_vhost_ssl_with_mock_span2(self):
+ # span includes the closing tag in newer versions
+ # of Augeas
+ return_value = [self.vh_truth[0].filep, 1, 12, 0, 0, 0, 1157]
+ with mock.patch.object(self.config.aug, 'span') as mock_span:
+ mock_span.return_value = return_value
+ self.test_make_vhost_ssl()
+
def test_make_vhost_ssl(self):
ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0])
@@ -655,11 +699,6 @@ class MultipleVhostsTest(util.ApacheTest):
len(self.config.parser.find_dir(
directive, None, self.vh_truth[1].path, False)), 0)
- 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
@@ -794,6 +833,15 @@ class MultipleVhostsTest(util.ApacheTest):
def test_supported_enhancements(self):
self.assertTrue(isinstance(self.config.supported_enhancements(), list))
+ def test_find_http_vhost_without_ancestor(self):
+ # pylint: disable=protected-access
+ vhost = self.vh_truth[0]
+ vhost.ssl = True
+ vhost.ancestor = None
+ res = self.config._get_http_vhost(vhost)
+ self.assertEqual(self.vh_truth[0].name, res.name)
+ self.assertEqual(self.vh_truth[0].aliases, res.aliases)
+
@mock.patch("certbot_apache.configurator.ApacheConfigurator._get_http_vhost")
@mock.patch("certbot_apache.display_ops.select_vhost")
@mock.patch("certbot.util.exe_exists")
@@ -993,8 +1041,9 @@ class MultipleVhostsTest(util.ApacheTest):
# 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))
+ # [:-3] to remove the vhost index number
+ self.assertTrue(rw_engine[0].startswith(self.vh_truth[3].path[:-3]))
+ self.assertTrue(rw_rule[0].startswith(self.vh_truth[3].path[:-3]))
self.assertTrue("rewrite_module" in self.config.parser.modules)
@@ -1042,9 +1091,9 @@ class MultipleVhostsTest(util.ApacheTest):
self.assertEqual(len(rw_engine), 1)
# three args to rw_rule + 1 arg for the pre existing rewrite
self.assertEqual(len(rw_rule), 5)
-
- self.assertTrue(rw_engine[0].startswith(self.vh_truth[3].path))
- self.assertTrue(rw_rule[0].startswith(self.vh_truth[3].path))
+ # [:-3] to remove the vhost index number
+ self.assertTrue(rw_engine[0].startswith(self.vh_truth[3].path[:-3]))
+ self.assertTrue(rw_rule[0].startswith(self.vh_truth[3].path[:-3]))
self.assertTrue("rewrite_module" in self.config.parser.modules)
@@ -1152,89 +1201,6 @@ class MultipleVhostsTest(util.ApacheTest):
not_rewriterule = "NotRewriteRule ^ ..."
self.assertFalse(self.config._sift_rewrite_rule(not_rewriterule))
- @certbot_util.patch_get_utility()
- def test_make_vhost_ssl_with_existing_rewrite_rule(self, mock_get_utility):
- self.config.parser.modules.add("rewrite_module")
-
- http_vhost = self.vh_truth[0]
-
- self.config.parser.add_dir(
- http_vhost.path, "RewriteEngine", "on")
-
- self.config.parser.add_dir(
- http_vhost.path, "RewriteRule",
- ["^",
- "https://%{SERVER_NAME}%{REQUEST_URI}",
- "[L,NE,R=permanent]"])
- self.config.save()
-
- ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0])
-
- self.assertTrue(self.config.parser.find_dir(
- "RewriteEngine", "on", ssl_vhost.path, False))
-
- conf_text = open(ssl_vhost.filep).read()
- commented_rewrite_rule = ("# RewriteRule ^ "
- "https://%{SERVER_NAME}%{REQUEST_URI} "
- "[L,NE,R=permanent]")
- self.assertTrue(commented_rewrite_rule in conf_text)
- mock_get_utility().add_message.assert_called_once_with(mock.ANY,
-
- mock.ANY)
- @certbot_util.patch_get_utility()
- def test_make_vhost_ssl_with_existing_rewrite_conds(self, mock_get_utility):
- self.config.parser.modules.add("rewrite_module")
-
- http_vhost = self.vh_truth[0]
-
- self.config.parser.add_dir(
- http_vhost.path, "RewriteEngine", "on")
-
- # Add a chunk that should not be commented out.
- self.config.parser.add_dir(http_vhost.path,
- "RewriteCond", ["%{DOCUMENT_ROOT}/%{REQUEST_FILENAME}", "!-f"])
- self.config.parser.add_dir(
- http_vhost.path, "RewriteRule",
- ["^(.*)$", "b://u%{REQUEST_URI}", "[P,NE,L]"])
-
- # Add a chunk that should be commented out.
- self.config.parser.add_dir(http_vhost.path,
- "RewriteCond", ["%{HTTPS}", "!=on"])
- self.config.parser.add_dir(http_vhost.path,
- "RewriteCond", ["%{HTTPS}", "!^$"])
- self.config.parser.add_dir(
- http_vhost.path, "RewriteRule",
- ["^",
- "https://%{SERVER_NAME}%{REQUEST_URI}",
- "[L,NE,R=permanent]"])
-
- self.config.save()
-
- ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[0])
-
- conf_line_set = set(open(ssl_vhost.filep).read().splitlines())
-
- not_commented_cond1 = ("RewriteCond "
- "%{DOCUMENT_ROOT}/%{REQUEST_FILENAME} !-f")
- not_commented_rewrite_rule = ("RewriteRule "
- "^(.*)$ b://u%{REQUEST_URI} [P,NE,L]")
-
- commented_cond1 = "# RewriteCond %{HTTPS} !=on"
- commented_cond2 = "# RewriteCond %{HTTPS} !^$"
- commented_rewrite_rule = ("# RewriteRule ^ "
- "https://%{SERVER_NAME}%{REQUEST_URI} "
- "[L,NE,R=permanent]")
-
- self.assertTrue(not_commented_cond1 in conf_line_set)
- self.assertTrue(not_commented_rewrite_rule in conf_line_set)
-
- self.assertTrue(commented_cond1 in conf_line_set)
- self.assertTrue(commented_cond2 in conf_line_set)
- self.assertTrue(commented_rewrite_rule in conf_line_set)
- mock_get_utility().add_message.assert_called_once_with(mock.ANY,
- mock.ANY)
-
-
def get_achalls(self):
"""Return testing achallenges."""
account_key = self.rsa512jwk
@@ -1345,6 +1311,126 @@ class AugeasVhostsTest(util.ApacheTest):
self.config.choose_vhost(name)
self.assertEqual(mock_select.call_count, 0)
+ def test_augeas_span_error(self):
+ broken_vhost = self.config.vhosts[0]
+ broken_vhost.path = broken_vhost.path + "/nonexistent"
+ self.assertRaises(errors.PluginError, self.config.make_vhost_ssl,
+ broken_vhost)
+
+class MultiVhostsTest(util.ApacheTest):
+ """Test vhosts with illegal names dependant on augeas version."""
+ # pylint: disable=protected-access
+
+ def setUp(self): # pylint: disable=arguments-differ
+ td = "debian_apache_2_4/multi_vhosts"
+ cr = "debian_apache_2_4/multi_vhosts/apache2"
+ vr = "debian_apache_2_4/multi_vhosts/apache2/sites-available"
+ super(MultiVhostsTest, self).setUp(test_dir=td,
+ config_root=cr,
+ vhost_root=vr)
+
+ self.config = util.get_apache_configurator(
+ self.config_path, self.vhost_path, self.config_dir, self.work_dir)
+ self.vh_truth = util.get_vh_truth(
+ self.temp_dir, "debian_apache_2_4/multi_vhosts")
+
+ def tearDown(self):
+ shutil.rmtree(self.temp_dir)
+ shutil.rmtree(self.config_dir)
+ shutil.rmtree(self.work_dir)
+
+ def test_make_vhost_ssl(self):
+ ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[1])
+
+ self.assertEqual(
+ ssl_vhost.filep,
+ os.path.join(self.config_path, "sites-available",
+ "default-le-ssl.conf"))
+
+ self.assertEqual(ssl_vhost.path,
+ "/files" + ssl_vhost.filep + "/IfModule/VirtualHost")
+ self.assertEqual(len(ssl_vhost.addrs), 1)
+ self.assertEqual(set([obj.Addr.fromstring("*:443")]), ssl_vhost.addrs)
+ self.assertEqual(ssl_vhost.name, "banana.vomit.com")
+ self.assertTrue(ssl_vhost.ssl)
+ self.assertFalse(ssl_vhost.enabled)
+
+ self.assertTrue(self.config.parser.find_dir(
+ "SSLCertificateFile", None, ssl_vhost.path, False))
+ self.assertTrue(self.config.parser.find_dir(
+ "SSLCertificateKeyFile", None, ssl_vhost.path, False))
+
+ self.assertEqual(self.config.is_name_vhost(self.vh_truth[1]),
+ self.config.is_name_vhost(ssl_vhost))
+
+ mock_path = "certbot_apache.configurator.ApacheConfigurator._get_new_vh_path"
+ with mock.patch(mock_path) as mock_getpath:
+ mock_getpath.return_value = None
+ self.assertRaises(errors.PluginError, self.config.make_vhost_ssl,
+ self.vh_truth[1])
+
+ def test_get_new_path(self):
+ with_index_1 = ["/path[1]/section[1]"]
+ without_index = ["/path/section"]
+ with_index_2 = ["/path[2]/section[2]"]
+ self.assertEqual(self.config._get_new_vh_path(without_index,
+ with_index_1),
+ None)
+ self.assertEqual(self.config._get_new_vh_path(without_index,
+ with_index_2),
+ with_index_2[0])
+
+ both = with_index_1 + with_index_2
+ self.assertEqual(self.config._get_new_vh_path(without_index, both),
+ with_index_2[0])
+
+ @certbot_util.patch_get_utility()
+ def test_make_vhost_ssl_with_existing_rewrite_rule(self, mock_get_utility):
+ self.config.parser.modules.add("rewrite_module")
+
+ ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[4])
+
+ self.assertTrue(self.config.parser.find_dir(
+ "RewriteEngine", "on", ssl_vhost.path, False))
+
+ conf_text = open(ssl_vhost.filep).read()
+ commented_rewrite_rule = ("# RewriteRule \"^/secrets/(.+)\" "
+ "\"https://new.example.com/docs/$1\" [R,L]")
+ uncommented_rewrite_rule = ("RewriteRule \"^/docs/(.+)\" "
+ "\"http://new.example.com/docs/$1\" [R,L]")
+ self.assertTrue(commented_rewrite_rule in conf_text)
+ self.assertTrue(uncommented_rewrite_rule in conf_text)
+ mock_get_utility().add_message.assert_called_once_with(mock.ANY,
+ mock.ANY)
+
+ @certbot_util.patch_get_utility()
+ def test_make_vhost_ssl_with_existing_rewrite_conds(self, mock_get_utility):
+ self.config.parser.modules.add("rewrite_module")
+
+ ssl_vhost = self.config.make_vhost_ssl(self.vh_truth[3])
+
+ conf_lines = open(ssl_vhost.filep).readlines()
+ conf_line_set = [l.strip() for l in conf_lines]
+ not_commented_cond1 = ("RewriteCond "
+ "%{DOCUMENT_ROOT}/%{REQUEST_FILENAME} !-f")
+ not_commented_rewrite_rule = ("RewriteRule "
+ "^(.*)$ b://u%{REQUEST_URI} [P,NE,L]")
+
+ commented_cond1 = "# RewriteCond %{HTTPS} !=on"
+ commented_cond2 = "# RewriteCond %{HTTPS} !^$"
+ commented_rewrite_rule = ("# RewriteRule ^ "
+ "https://%{SERVER_NAME}%{REQUEST_URI} "
+ "[L,NE,R=permanent]")
+
+ self.assertTrue(not_commented_cond1 in conf_line_set)
+ self.assertTrue(not_commented_rewrite_rule in conf_line_set)
+
+ self.assertTrue(commented_cond1 in conf_line_set)
+ self.assertTrue(commented_cond2 in conf_line_set)
+ self.assertTrue(commented_rewrite_rule in conf_line_set)
+ mock_get_utility().add_message.assert_called_once_with(mock.ANY,
+ mock.ANY)
+
if __name__ == "__main__":
unittest.main() # pragma: no cover
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/apache2.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/apache2.conf
new file mode 100644
index 000000000..2a5bb7be2
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/apache2.conf
@@ -0,0 +1,196 @@
+# This is the main Apache server configuration file. It contains the
+# configuration directives that give the server its instructions.
+# See http://httpd.apache.org/docs/2.4/ for detailed information about
+# the directives and /usr/share/doc/apache2/README.Debian about Debian specific
+# hints.
+#
+#
+# Summary of how the Apache 2 configuration works in Debian:
+# The Apache 2 web server configuration in Debian is quite different to
+# upstream's suggested way to configure the web server. This is because Debian's
+# default Apache2 installation attempts to make adding and removing modules,
+# virtual hosts, and extra configuration directives as flexible as possible, in
+# order to make automating the changes and administering the server as easy as
+# possible.
+
+# It is split into several files forming the configuration hierarchy outlined
+# below, all located in the /etc/apache2/ directory:
+#
+# /etc/apache2/
+# |-- apache2.conf
+# | `-- ports.conf
+# |-- mods-enabled
+# | |-- *.load
+# | `-- *.conf
+# |-- conf-enabled
+# | `-- *.conf
+# `-- sites-enabled
+# `-- *.conf
+#
+#
+# * apache2.conf is the main configuration file (this file). It puts the pieces
+# together by including all remaining configuration files when starting up the
+# web server.
+#
+# * ports.conf is always included from the main configuration file. It is
+# supposed to determine listening ports for incoming connections which can be
+# customized anytime.
+#
+# * Configuration files in the mods-enabled/, conf-enabled/ and sites-enabled/
+# directories contain particular configuration snippets which manage modules,
+# global configuration fragments, or virtual host configurations,
+# respectively.
+#
+# They are activated by symlinking available configuration files from their
+# respective *-available/ counterparts. These should be managed by using our
+# helpers a2enmod/a2dismod, a2ensite/a2dissite and a2enconf/a2disconf. See
+# their respective man pages for detailed information.
+#
+# * The binary is called apache2. Due to the use of environment variables, in
+# the default configuration, apache2 needs to be started/stopped with
+# /etc/init.d/apache2 or apache2ctl. Calling /usr/bin/apache2 directly will not
+# work with the default configuration.
+
+
+# Global configuration
+
+#
+# The accept serialization lock file MUST BE STORED ON A LOCAL DISK.
+#
+Mutex file:${APACHE_LOCK_DIR} default
+
+#
+# PidFile: The file in which the server should record its process
+# identification number when it starts.
+# This needs to be set in /etc/apache2/envvars
+#
+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
+
+#
+# MaxKeepAliveRequests: The maximum number of requests to allow
+# during a persistent connection. Set to 0 to allow an unlimited amount.
+# We recommend you leave this number high, for maximum performance.
+#
+MaxKeepAliveRequests 100
+
+#
+# KeepAliveTimeout: Number of seconds to wait for the next request from the
+# same client on the same connection.
+#
+KeepAliveTimeout 5
+
+
+# These need to be set in /etc/apache2/envvars
+User ${APACHE_RUN_USER}
+Group ${APACHE_RUN_GROUP}
+
+#
+# HostnameLookups: Log the names of clients or just their IP addresses
+# e.g., www.apache.org (on) or 204.62.129.132 (off).
+# The default is off because it'd be overall better for the net if people
+# had to knowingly turn this feature on, since enabling it means that
+# each client request will result in AT LEAST one lookup request to the
+# nameserver.
+#
+HostnameLookups Off
+
+# ErrorLog: The location of the error log file.
+# If you do not specify an ErrorLog directive within a
+# container, error messages relating to that virtual host will be
+# logged here. If you *do* define an error logfile for a
+# container, that host's errors will be logged there and not here.
+#
+ErrorLog ${APACHE_LOG_DIR}/error.log
+
+#
+# LogLevel: Control the severity of messages logged to the error_log.
+# Available values: trace8, ..., trace1, debug, info, notice, warn,
+# error, crit, alert, emerg.
+# It is also possible to configure the log level for particular modules, e.g.
+# "LogLevel info ssl:warn"
+#
+LogLevel warn
+
+# Include module configuration:
+IncludeOptional mods-enabled/*.load
+IncludeOptional mods-enabled/*.conf
+
+# Include list of ports to listen on
+Include ports.conf
+
+
+# Sets the default security model of the Apache2 HTTPD server. It does
+# not allow access to the root filesystem outside of /usr/share and /var/www.
+# The former is used by web applications packaged in Debian,
+# the latter may be used for local directories served by the web server. If
+# your system is serving content from a sub-directory in /srv you must allow
+# access here, or in any related virtual host.
+
+ Options FollowSymLinks
+ AllowOverride None
+ Require all denied
+
+
+
+ AllowOverride None
+ Require all granted
+
+
+
+ Options Indexes FollowSymLinks
+ AllowOverride None
+ Require all granted
+
+
+# AccessFileName: The name of the file to look for in each directory
+# for additional configuration directives. See also the AllowOverride
+# directive.
+#
+AccessFileName .htaccess
+
+#
+# The following lines prevent .htaccess and .htpasswd files from being
+# viewed by Web clients.
+#
+
+ Require all denied
+
+
+# The following directives define some format nicknames for use with
+# a CustomLog directive.
+#
+# These deviate from the Common Log Format definitions in that they use %O
+# (the actual bytes sent including headers) instead of %b (the size of the
+# requested file), because the latter makes it impossible to detect partial
+# requests.
+#
+# Note that the use of %{X-Forwarded-For}i instead of %h is not recommended.
+# Use mod_remoteip instead.
+#
+LogFormat "%v:%p %h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" vhost_combined
+LogFormat "%h %l %u %t \"%r\" %>s %O \"%{Referer}i\" \"%{User-Agent}i\"" combined
+LogFormat "%h %l %u %t \"%r\" %>s %O" common
+LogFormat "%{Referer}i -> %U" referer
+LogFormat "%{User-agent}i" agent
+
+# Include of directories ignores editors' and dpkg's backup files,
+# see README.Debian for details.
+
+# Include generic snippets of statements
+IncludeOptional conf-enabled/*.conf
+
+# Include the virtual host configurations:
+IncludeOptional sites-enabled/*.conf
+
+# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/envvars b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/envvars
new file mode 100644
index 000000000..a13d9a89e
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/envvars
@@ -0,0 +1,29 @@
+# envvars - default environment variables for apache2ctl
+
+# this won't be correct after changing uid
+unset HOME
+
+# for supporting multiple apache2 instances
+if [ "${APACHE_CONFDIR##/etc/apache2-}" != "${APACHE_CONFDIR}" ] ; then
+ SUFFIX="-${APACHE_CONFDIR##/etc/apache2-}"
+else
+ SUFFIX=
+fi
+
+# Since there is no sane way to get the parsed apache2 config in scripts, some
+# settings are defined via environment variables and then used in apache2ctl,
+# /etc/init.d/apache2, /etc/logrotate.d/apache2, etc.
+export APACHE_RUN_USER=www-data
+export APACHE_RUN_GROUP=www-data
+# temporary state file location. This might be changed to /run in Wheezy+1
+export APACHE_PID_FILE=/var/run/apache2/apache2$SUFFIX.pid
+export APACHE_RUN_DIR=/var/run/apache2$SUFFIX
+export APACHE_LOCK_DIR=/var/lock/apache2$SUFFIX
+# Only /var/log/apache2 is handled by /etc/logrotate.d/apache2.
+export APACHE_LOG_DIR=/var/log/apache2$SUFFIX
+
+## The locale used by some modules like mod_dav
+export LANG=C
+
+export LANG
+
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/ports.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/ports.conf
new file mode 100644
index 000000000..5daec58c1
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/ports.conf
@@ -0,0 +1,15 @@
+# If you just change the port or add more ports here, you will likely also
+# have to change the VirtualHost statement in
+# /etc/apache2/sites-enabled/000-default.conf
+
+Listen 80
+
+
+ Listen 443
+
+
+
+ Listen 443
+
+
+# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-available/default.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-available/default.conf
new file mode 100644
index 000000000..6ab206b2d
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-available/default.conf
@@ -0,0 +1,22 @@
+
+
+ ServerName banana.vomit.net
+ ServerAdmin webmaster@localhost
+ DocumentRoot /var/www/html
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+
+
+
+
+ ServerName banana.vomit.com
+ ServerAdmin webmaster@localhost
+ DocumentRoot /var/www/html
+
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+
+# vim: syntax=apache ts=4 sw=4 sts=4 sr noet
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-available/multi-vhost.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-available/multi-vhost.conf
new file mode 100644
index 000000000..5f2b727bf
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-available/multi-vhost.conf
@@ -0,0 +1,38 @@
+
+ ServerName 1.multi.vhost.tld
+ ServerAlias first.multi.vhost.tld
+ ServerAdmin webmaster@localhost
+ DocumentRoot /var/www/html
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+
+
+
+
+ ServerName 2.multi.vhost.tld
+ ServerAlias second.multi.vhost.tld
+ ServerAdmin webmaster@localhost
+ DocumentRoot /var/www/html
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+RewriteEngine on
+RewriteCond %{DOCUMENT_ROOT}/%{REQUEST_FILENAME} !-f
+RewriteRule ^(.*)$ b://u%{REQUEST_URI} [P,NE,L]
+RewriteCond %{HTTPS} !=on
+RewriteCond %{HTTPS} !^$
+RewriteRule ^ https://%{SERVER_NAME}%{REQUEST_URI} [L,NE,R=permanent]
+
+
+
+
+ ServerName 3.multi.vhost.tld
+ ServerAlias third.multi.vhost.tld
+ ServerAdmin webmaster@localhost
+ DocumentRoot /var/www/html
+ ErrorLog ${APACHE_LOG_DIR}/error.log
+ CustomLog ${APACHE_LOG_DIR}/access.log combined
+RewriteEngine on
+RewriteRule "^/secrets/(.+)" "https://new.example.com/docs/$1" [R,L]
+RewriteRule "^/docs/(.+)" "http://new.example.com/docs/$1" [R,L]
+
+
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-enabled/default.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-enabled/default.conf
new file mode 120000
index 000000000..032e6bcf0
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-enabled/default.conf
@@ -0,0 +1 @@
+../sites-available/default.conf
\ No newline at end of file
diff --git a/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-enabled/multi-vhost.conf b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-enabled/multi-vhost.conf
new file mode 120000
index 000000000..7f0910ff4
--- /dev/null
+++ b/certbot-apache/certbot_apache/tests/testdata/debian_apache_2_4/multi_vhosts/apache2/sites-enabled/multi-vhost.conf
@@ -0,0 +1 @@
+../sites-available/multi-vhost.conf
\ No newline at end of file
diff --git a/certbot-apache/certbot_apache/tests/util.py b/certbot-apache/certbot_apache/tests/util.py
index 3c33a0e19..d40152ad5 100644
--- a/certbot-apache/certbot_apache/tests/util.py
+++ b/certbot-apache/certbot_apache/tests/util.py
@@ -164,5 +164,35 @@ def get_vh_truth(temp_dir, config_name):
set([obj.Addr.fromstring("10.2.3.4:443")]), True, True,
"ocspvhost.com")]
return vh_truth
-
+ if config_name == "debian_apache_2_4/multi_vhosts":
+ prefix = os.path.join(
+ temp_dir, config_name, "apache2/sites-available")
+ aug_pre = "/files" + prefix
+ vh_truth = [
+ obj.VirtualHost(
+ os.path.join(prefix, "default.conf"),
+ os.path.join(aug_pre, "default.conf/VirtualHost[1]"),
+ set([obj.Addr.fromstring("*:80")]),
+ False, True, "ip-172-30-0-17"),
+ obj.VirtualHost(
+ os.path.join(prefix, "default.conf"),
+ os.path.join(aug_pre, "default.conf/VirtualHost[2]"),
+ set([obj.Addr.fromstring("*:80")]),
+ False, True, "banana.vomit.com"),
+ obj.VirtualHost(
+ os.path.join(prefix, "multi-vhost.conf"),
+ os.path.join(aug_pre, "multi-vhost.conf/VirtualHost[1]"),
+ set([obj.Addr.fromstring("*:80")]),
+ False, True, "1.multi.vhost.tld"),
+ obj.VirtualHost(
+ os.path.join(prefix, "multi-vhost.conf"),
+ os.path.join(aug_pre, "multi-vhost.conf/IfModule/VirtualHost"),
+ set([obj.Addr.fromstring("*:80")]),
+ False, True, "2.multi.vhost.tld"),
+ obj.VirtualHost(
+ os.path.join(prefix, "multi-vhost.conf"),
+ os.path.join(aug_pre, "multi-vhost.conf/VirtualHost[2]"),
+ set([obj.Addr.fromstring("*:80")]),
+ False, True, "3.multi.vhost.tld")]
+ return vh_truth
return None # pragma: no cover
diff --git a/certbot-apache/setup.cfg b/certbot-apache/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-apache/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-apache/setup.py b/certbot-apache/setup.py
index 9a473c584..ccd7cbc2a 100644
--- a/certbot-apache/setup.py
+++ b/certbot-apache/setup.py
@@ -4,14 +4,14 @@ from setuptools import setup
from setuptools import find_packages
-version = '0.14.0.dev0'
+version = '0.15.0.dev0'
# Please update tox.ini when modifying dependency version requirements
install_requires = [
'acme=={0}'.format(version),
'certbot=={0}'.format(version),
'mock',
- 'python-augeas<=0.5.0',
+ 'python-augeas',
# For pkg_resources. >=1.0 so pip resolves it to a version cryptography
# will tolerate; see #2599:
'setuptools>=1.0',
@@ -42,6 +42,11 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Security',
'Topic :: System :: Installation/Setup',
diff --git a/certbot-auto b/certbot-auto
index fc8007c9e..39edbb3c5 100755
--- a/certbot-auto
+++ b/certbot-auto
@@ -15,6 +15,11 @@ set -e # Work even if somebody does "sh thisscript.sh".
# 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
+
+# HOME might not be defined when being run through something like systemd
+if [ -z "$HOME" ]; then
+ HOME=~root
+fi
if [ -z "$XDG_DATA_HOME" ]; then
XDG_DATA_HOME=~/.local/share
fi
@@ -23,7 +28,7 @@ if [ -z "$VENV_PATH" ]; then
VENV_PATH="$XDG_DATA_HOME/$VENV_NAME"
fi
VENV_BIN="$VENV_PATH/bin"
-LE_AUTO_VERSION="0.13.0"
+LE_AUTO_VERSION="0.14.1"
BASENAME=$(basename $0)
USAGE="Usage: $BASENAME [OPTIONS]
A self-updating wrapper script for the Certbot ACME client. When run, updates
@@ -59,7 +64,7 @@ for arg in "$@" ; do
NO_BOOTSTRAP=1;;
--help)
HELP=1;;
- --noninteractive|--non-interactive)
+ --noninteractive|--non-interactive|renew)
ASSUME_YES=1;;
--quiet)
QUIET=1;;
@@ -93,6 +98,16 @@ if [ "$QUIET" = 1 ]; then
ASSUME_YES=1
fi
+say() {
+ if [ "$QUIET" != 1 ]; then
+ echo "$@"
+ fi
+}
+
+error() {
+ echo "$@"
+}
+
# Support for busybox and others where there is no "command",
# but "which" instead
if command -v command > /dev/null 2>&1 ; then
@@ -100,7 +115,7 @@ if command -v command > /dev/null 2>&1 ; then
elif which which > /dev/null 2>&1 ; then
export EXISTS="which"
else
- echo "Cannot find command nor which... please install one!"
+ error "Cannot find command nor which... please install one!"
exit 1
fi
@@ -145,17 +160,17 @@ if [ -n "${LE_AUTO_SUDO+x}" ]; then
;;
'') ;; # Nothing to do for plain root method.
*)
- echo "Error: unknown root authorization mechanism '$LE_AUTO_SUDO'."
+ error "Error: unknown root authorization mechanism '$LE_AUTO_SUDO'."
exit 1
esac
- echo "Using preset root authorization mechanism '$LE_AUTO_SUDO'."
+ say "Using preset root authorization mechanism '$LE_AUTO_SUDO'."
else
if test "`id -u`" -ne "0" ; then
if $EXISTS sudo 1>/dev/null 2>&1; then
SUDO=sudo
SUDO_ENV="CERTBOT_AUTO=$0"
else
- echo \"sudo\" is not available, will use \"su\" for installation steps...
+ say \"sudo\" is not available, will use \"su\" for installation steps...
SUDO=su_sudo
fi
else
@@ -165,7 +180,7 @@ fi
BootstrapMessage() {
# Arguments: Platform name
- echo "Bootstrapping dependencies for $1... (you can skip this with --no-bootstrap)"
+ say "Bootstrapping dependencies for $1... (you can skip this with --no-bootstrap)"
}
ExperimentalBootstrap() {
@@ -176,11 +191,11 @@ ExperimentalBootstrap() {
$2
fi
else
- echo "FATAL: $1 support is very experimental at present..."
- echo "if you would like to work on improving it, please ensure you have backups"
- echo "and then run this script again with the --debug flag!"
- echo "Alternatively, you can install OS dependencies yourself and run this script"
- echo "again with --no-bootstrap."
+ error "FATAL: $1 support is very experimental at present..."
+ error "if you would like to work on improving it, please ensure you have backups"
+ error "and then run this script again with the --debug flag!"
+ error "Alternatively, you can install OS dependencies yourself and run this script"
+ error "again with --no-bootstrap."
exit 1
fi
}
@@ -191,15 +206,15 @@ DeterminePythonVersion() {
$EXISTS "$LE_PYTHON" > /dev/null && break
done
if [ "$?" != "0" ]; then
- echo "Cannot find any Pythons; please install one!"
+ error "Cannot find any Pythons; please install one!"
exit 1
fi
export LE_PYTHON
PYVER=`"$LE_PYTHON" -V 2>&1 | cut -d" " -f 2 | cut -d. -f1,2 | sed 's/\.//'`
if [ "$PYVER" -lt 26 ]; then
- echo "You have an ancient version of Python entombed in your operating system..."
- echo "This isn't going to work; you'll need at least version 2.6."
+ error "You have an ancient version of Python entombed in your operating system..."
+ error "This isn't going to work; you'll need at least version 2.6."
exit 1
fi
}
@@ -227,7 +242,7 @@ BootstrapDebCommon() {
QUIET_FLAG='-qq'
fi
- $SUDO apt-get $QUIET_FLAG update || echo apt-get update hit problems but continuing anyway...
+ $SUDO apt-get $QUIET_FLAG update || error apt-get update hit problems but continuing anyway...
# virtualenv binary can be found in different packages depending on
# distro version (#346)
@@ -255,7 +270,7 @@ BootstrapDebCommon() {
# ARGS:
BACKPORT_NAME="$1"
BACKPORT_SOURCELINE="$2"
- echo "To use the Apache Certbot plugin, augeas needs to be installed from $BACKPORT_NAME."
+ say "To use the Apache Certbot plugin, augeas needs to be installed from $BACKPORT_NAME."
if ! grep -v -e ' *#' /etc/apt/sources.list | grep -q "$BACKPORT_NAME" ; then
# This can theoretically error if sources.list.d is empty, but in that case we don't care.
if ! grep -v -e ' *#' /etc/apt/sources.list.d/* 2>/dev/null | grep -q "$BACKPORT_NAME"; then
@@ -315,7 +330,7 @@ BootstrapDebCommon() {
if ! $EXISTS virtualenv > /dev/null ; then
- echo Failed to install a working \"virtualenv\" command, exiting
+ error Failed to install a working \"virtualenv\" command, exiting
exit 1
fi
}
@@ -335,7 +350,7 @@ BootstrapRpmCommon() {
tool=yum
else
- echo "Neither yum nor dnf found. Aborting bootstrap!"
+ error "Neither yum nor dnf found. Aborting bootstrap!"
exit 1
fi
@@ -349,7 +364,7 @@ BootstrapRpmCommon() {
if ! $SUDO $tool list *virtualenv >/dev/null 2>&1; then
echo "To use Certbot, packages from the EPEL repository need to be installed."
if ! $SUDO $tool list epel-release >/dev/null 2>&1; then
- echo "Please enable this repository and try running Certbot again."
+ error "Enable the EPEL repository and try running Certbot again."
exit 1
fi
if [ "$ASSUME_YES" = 1 ]; then
@@ -361,7 +376,7 @@ BootstrapRpmCommon() {
sleep 1s
fi
if ! $SUDO $tool install $yes_flag $QUIET_FLAG epel-release; then
- echo "Could not enable EPEL. Aborting bootstrap!"
+ error "Could not enable EPEL. Aborting bootstrap!"
exit 1
fi
fi
@@ -403,7 +418,7 @@ BootstrapRpmCommon() {
fi
if ! $SUDO $tool install $yes_flag $QUIET_FLAG $pkgs; then
- echo "Could not install OS dependencies. Aborting bootstrap!"
+ error "Could not install OS dependencies. Aborting bootstrap!"
exit 1
fi
}
@@ -508,15 +523,15 @@ BootstrapFreeBsd() {
BootstrapMac() {
if hash brew 2>/dev/null; then
- echo "Using Homebrew to install dependencies..."
+ say "Using Homebrew to install dependencies..."
pkgman=brew
pkgcmd="brew install"
elif hash port 2>/dev/null; then
- echo "Using MacPorts to install dependencies..."
+ say "Using MacPorts to install dependencies..."
pkgman=port
pkgcmd="$SUDO port install"
else
- echo "No Homebrew/MacPorts; installing Homebrew..."
+ say "No Homebrew/MacPorts; installing Homebrew..."
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
pkgman=brew
pkgcmd="brew install"
@@ -527,26 +542,26 @@ BootstrapMac() {
-o "$(which python)" = "/usr/bin/python" ]; then
# We want to avoid using the system Python because it requires root to use pip.
# python.org, MacPorts or HomeBrew Python installations should all be OK.
- echo "Installing python..."
+ say "Installing python..."
$pkgcmd python
fi
# Workaround for _dlopen not finding augeas on macOS
if [ "$pkgman" = "port" ] && ! [ -e "/usr/local/lib/libaugeas.dylib" ] && [ -e "/opt/local/lib/libaugeas.dylib" ]; then
- echo "Applying augeas workaround"
+ say "Applying augeas workaround"
$SUDO mkdir -p /usr/local/lib/
$SUDO ln -s /opt/local/lib/libaugeas.dylib /usr/local/lib/
fi
if ! hash pip 2>/dev/null; then
- echo "pip not installed"
- echo "Installing pip..."
+ say "pip not installed"
+ say "Installing pip..."
curl --silent --show-error --retry 5 https://bootstrap.pypa.io/get-pip.py | python
fi
if ! hash virtualenv 2>/dev/null; then
- echo "virtualenv not installed."
- echo "Installing with pip..."
+ say "virtualenv not installed."
+ say "Installing with pip..."
pip install virtualenv
fi
}
@@ -566,7 +581,7 @@ BootstrapMageiaCommon() {
libpython-devel \
python-virtualenv
then
- echo "Could not install Python dependencies. Aborting bootstrap!"
+ error "Could not install Python dependencies. Aborting bootstrap!"
exit 1
fi
@@ -578,7 +593,7 @@ BootstrapMageiaCommon() {
libffi-devel \
rootcerts
then
- echo "Could not install additional dependencies. Aborting bootstrap!"
+ error "Could not install additional dependencies. Aborting bootstrap!"
exit 1
fi
}
@@ -605,11 +620,11 @@ Bootstrap() {
BootstrapMessage "Archlinux"
BootstrapArchCommon
else
- echo "Please use pacman to install letsencrypt packages:"
- echo "# pacman -S certbot certbot-apache"
- echo
- echo "If you would like to use the virtualenv way, please run the script again with the"
- echo "--debug flag."
+ error "Please use pacman to install letsencrypt packages:"
+ error "# pacman -S certbot certbot-apache"
+ error
+ error "If you would like to use the virtualenv way, please run the script again with the"
+ error "--debug flag."
exit 1
fi
elif [ -f /etc/manjaro-release ]; then
@@ -625,11 +640,11 @@ Bootstrap() {
elif [ -f /etc/product ] && grep -q "Joyent Instance" /etc/product ; then
ExperimentalBootstrap "Joyent SmartOS Zone" BootstrapSmartOS
else
- echo "Sorry, I don't know how to bootstrap Certbot on your operating system!"
- echo
- echo "You will need to install OS dependencies, configure virtualenv, and run pip install manually."
- echo "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites"
- echo "for more info."
+ error "Sorry, I don't know how to bootstrap Certbot on your operating system!"
+ error
+ error "You will need to install OS dependencies, configure virtualenv, and run pip install manually."
+ error "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites"
+ error "for more info."
exit 1
fi
}
@@ -649,7 +664,7 @@ if [ "$1" = "--le-auto-phase2" ]; then
# grep for both certbot and letsencrypt until certbot and shim packages have been released
INSTALLED_VERSION=$("$VENV_BIN/letsencrypt" --version 2>&1 | grep "^certbot\|^letsencrypt" | cut -d " " -f 2)
if [ -z "$INSTALLED_VERSION" ]; then
- echo "Error: couldn't get currently installed version for $VENV_BIN/letsencrypt: " 1>&2
+ error "Error: couldn't get currently installed version for $VENV_BIN/letsencrypt: " 1>&2
"$VENV_BIN/letsencrypt" --version
exit 1
fi
@@ -657,7 +672,7 @@ if [ "$1" = "--le-auto-phase2" ]; then
INSTALLED_VERSION="none"
fi
if [ "$LE_AUTO_VERSION" != "$INSTALLED_VERSION" ]; then
- echo "Creating virtual environment..."
+ say "Creating virtual environment..."
DeterminePythonVersion
rm -rf "$VENV_PATH"
if [ "$VERBOSE" = 1 ]; then
@@ -666,7 +681,7 @@ if [ "$1" = "--le-auto-phase2" ]; then
virtualenv --no-site-packages --python "$LE_PYTHON" "$VENV_PATH" > /dev/null
fi
- echo "Installing Python packages..."
+ say "Installing Python packages..."
TEMP_DIR=$(TempDir)
trap 'rm -rf "$TEMP_DIR"' EXIT
# There is no $ interpolation due to quotes on starting heredoc delimiter.
@@ -845,18 +860,18 @@ letsencrypt==0.7.0 \
# THE LINES BELOW ARE EDITED BY THE RELEASE SCRIPT; ADD ALL DEPENDENCIES ABOVE.
-acme==0.13.0 \
- --hash=sha256:103ce8bed43aad1a9655ed815df09bbeab86ee16cc82137b44d9dac68faa394f \
- --hash=sha256:7489b3e20d02da0a389aedb82408ffb6b76294e41d833db85591b9f779539815
-certbot==0.13.0 \
- --hash=sha256:65d0d9d158972aff7746d4ef80a20465a14c54ae8bcb879216970c2a1b34503c \
- --hash=sha256:f63ad7747edaca2fb7d60c28882e44d2f48ff1cca9b9c7c251ad47e2189c00f3
-certbot-apache==0.13.0 \
- --hash=sha256:22f7c1dc93439384c0874960081d66957910c6dc737a9facbd9fcbc46c545874 \
- --hash=sha256:b43b04b53005e7218a09a0ba4d97581fab369e929472fa49fb55d29d0ab54589
-certbot-nginx==0.13.0 \
- --hash=sha256:9d0ab4eeb98b0ebad70ba116b32268342ad343d82d64990a652ff8072959b044 \
- --hash=sha256:f026a8faee8397a22c5d4a7623a6ef7c7e780ed63a3bdf9940f43f7823aa2a72
+acme==0.14.1 \
+ --hash=sha256:f535d6459dcafa436749a8d2fdfafed21b792efa05b8bd3263fcd739c2e1497c \
+ --hash=sha256:0e6d9d1bbb71d80c61c8d10ab9a40bcf38e25f0fa016b9769e96ebf5a79b552b
+certbot==0.14.1 \
+ --hash=sha256:f950a058d4f657160de4ad163d9f781fe7adeec0c0a44556841adb03ad135d13 \
+ --hash=sha256:519b28124869d97116cb1f2f04ccc2937c0b2fd32fce43576eb80c0e4ff1ab65
+certbot-apache==0.14.1 \
+ --hash=sha256:1dda9b4dcf66f6dfba37c787d849e69ad25a344572f74a76fc4447bb1a5417b2 \
+ --hash=sha256:da84996e345fc5789da3575225536b27fa3b35f89b2db2d8f494a34bced14f9b
+certbot-nginx==0.14.1 \
+ --hash=sha256:bd3d4a1dcd6fa9e8ead19a9da88693f08b63464c86be2442e42cd60565c3f05f \
+ --hash=sha256:f0c19f667072e4cfa6b92abf8312b6bee3ed1d2432676b211593034e7d1abb7e
UNLIKELY_EOF
# -------------------------------------------------------------------------
@@ -1022,42 +1037,40 @@ UNLIKELY_EOF
set -e
if [ "$PIP_STATUS" != 0 ]; then
# Report error. (Otherwise, be quiet.)
- echo "Had a problem while installing Python packages."
+ error "Had a problem while installing Python packages."
if [ "$VERBOSE" != 1 ]; then
- echo
- echo "pip prints the following errors: "
- echo "====================================================="
- echo "$PIP_OUT"
- echo "====================================================="
- echo
- echo "Certbot has problem setting up the virtual environment."
+ error
+ error "pip prints the following errors: "
+ error "====================================================="
+ error "$PIP_OUT"
+ error "====================================================="
+ error
+ error "Certbot has problem setting up the virtual environment."
if `echo $PIP_OUT | grep -q Killed` || `echo $PIP_OUT | grep -q "allocate memory"` ; then
- echo
- echo "Based on your pip output, the problem can likely be fixed by "
- echo "increasing the available memory."
+ error
+ error "Based on your pip output, the problem can likely be fixed by "
+ error "increasing the available memory."
else
- echo
- echo "We were not be able to guess the right solution from your pip "
- echo "output."
+ error
+ error "We were not be able to guess the right solution from your pip "
+ error "output."
fi
- echo
- echo "Consult https://certbot.eff.org/docs/install.html#problems-with-python-virtual-environment"
- echo "for possible solutions."
- echo "You may also find some support resources at https://certbot.eff.org/support/ ."
+ error
+ error "Consult https://certbot.eff.org/docs/install.html#problems-with-python-virtual-environment"
+ error "for possible solutions."
+ error "You may also find some support resources at https://certbot.eff.org/support/ ."
fi
rm -rf "$VENV_PATH"
exit 1
fi
- echo "Installation succeeded."
+ say "Installation succeeded."
fi
if [ -n "$SUDO" ]; then
# SUDO is su wrapper or sudo
- if [ "$QUIET" != 1 ]; then
- echo "Requesting root privileges to run certbot..."
- echo " $VENV_BIN/letsencrypt" "$@"
- fi
+ say "Requesting root privileges to run certbot..."
+ say " $VENV_BIN/letsencrypt" "$@"
fi
if [ -z "$SUDO_ENV" ] ; then
# SUDO is su wrapper / noop
@@ -1084,7 +1097,7 @@ else
Bootstrap
fi
if [ "$OS_PACKAGES_ONLY" = 1 ]; then
- echo "OS packages installed."
+ say "OS packages installed."
exit 0
fi
@@ -1227,9 +1240,9 @@ UNLIKELY_EOF
# ---------------------------------------------------------------------------
DeterminePythonVersion
if ! REMOTE_VERSION=`"$LE_PYTHON" "$TEMP_DIR/fetch.py" --latest-version` ; then
- echo "WARNING: unable to check for updates."
+ error "WARNING: unable to check for updates."
elif [ "$LE_AUTO_VERSION" != "$REMOTE_VERSION" ]; then
- echo "Upgrading certbot-auto $LE_AUTO_VERSION to $REMOTE_VERSION..."
+ say "Upgrading certbot-auto $LE_AUTO_VERSION to $REMOTE_VERSION..."
# Now we drop into Python so we don't have to install even more
# dependencies (curl, etc.), for better flow control, and for the option of
@@ -1238,7 +1251,7 @@ UNLIKELY_EOF
# Install new copy of certbot-auto.
# TODO: Deal with quotes in pathnames.
- echo "Replacing certbot-auto..."
+ say "Replacing certbot-auto..."
# Clone permissions with cp. chmod and chown don't have a --reference
# option on macOS or BSD, and stat -c on Linux is stat -f on macOS and BSD:
$SUDO cp -p "$0" "$TEMP_DIR/letsencrypt-auto.permission-clone"
diff --git a/certbot-compatibility-test/setup.cfg b/certbot-compatibility-test/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-compatibility-test/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-compatibility-test/setup.py b/certbot-compatibility-test/setup.py
index aecae329f..c5ec54e22 100644
--- a/certbot-compatibility-test/setup.py
+++ b/certbot-compatibility-test/setup.py
@@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
-version = '0.14.0.dev0'
+version = '0.15.0.dev0'
install_requires = [
'certbot',
@@ -42,6 +42,11 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Security',
],
diff --git a/certbot-dns-cloudflare/LICENSE.txt b/certbot-dns-cloudflare/LICENSE.txt
new file mode 100644
index 000000000..981c46c9f
--- /dev/null
+++ b/certbot-dns-cloudflare/LICENSE.txt
@@ -0,0 +1,190 @@
+ Copyright 2015 Electronic Frontier Foundation and others
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/certbot-dns-cloudflare/MANIFEST.in b/certbot-dns-cloudflare/MANIFEST.in
new file mode 100644
index 000000000..18f018c08
--- /dev/null
+++ b/certbot-dns-cloudflare/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE.txt
+include README.rst
+recursive-include docs *
diff --git a/certbot-dns-cloudflare/README.rst b/certbot-dns-cloudflare/README.rst
new file mode 100644
index 000000000..ff69eeaac
--- /dev/null
+++ b/certbot-dns-cloudflare/README.rst
@@ -0,0 +1 @@
+Cloudflare DNS Authenticator plugin for Certbot
diff --git a/certbot-dns-cloudflare/certbot_dns_cloudflare/__init__.py b/certbot-dns-cloudflare/certbot_dns_cloudflare/__init__.py
new file mode 100644
index 000000000..f4820e1ca
--- /dev/null
+++ b/certbot-dns-cloudflare/certbot_dns_cloudflare/__init__.py
@@ -0,0 +1 @@
+"""Cloudflare DNS Authenticator"""
diff --git a/certbot-dns-cloudflare/certbot_dns_cloudflare/dns_cloudflare.py b/certbot-dns-cloudflare/certbot_dns_cloudflare/dns_cloudflare.py
new file mode 100644
index 000000000..6979581ee
--- /dev/null
+++ b/certbot-dns-cloudflare/certbot_dns_cloudflare/dns_cloudflare.py
@@ -0,0 +1,198 @@
+"""DNS Authenticator for Cloudflare."""
+import logging
+
+import CloudFlare
+import zope.interface
+
+from certbot import errors
+from certbot import interfaces
+from certbot.plugins import dns_common
+
+logger = logging.getLogger(__name__)
+
+ACCOUNT_URL = 'https://www.cloudflare.com/a/account/my-account'
+
+
+@zope.interface.implementer(interfaces.IAuthenticator)
+@zope.interface.provider(interfaces.IPluginFactory)
+class Authenticator(dns_common.DNSAuthenticator):
+ """DNS Authenticator for Cloudflare
+
+ This Authenticator uses the Cloudflare API to fulfill a dns-01 challenge.
+ """
+
+ description = 'Obtain certs using a DNS TXT record (if you are using Cloudflare for DNS).'
+ ttl = 120
+
+ def __init__(self, *args, **kwargs):
+ super(Authenticator, self).__init__(*args, **kwargs)
+ self.credentials = None
+
+ @classmethod
+ def add_parser_arguments(cls, add): # pylint: disable=arguments-differ
+ super(Authenticator, cls).add_parser_arguments(add)
+ add('credentials', help='Cloudflare credentials INI file.')
+
+ def more_info(self): # pylint: disable=missing-docstring,no-self-use
+ return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
+ 'the Cloudflare API.'
+
+ def _setup_credentials(self):
+ self.credentials = self._configure_credentials(
+ 'credentials',
+ 'Cloudflare credentials INI file',
+ {
+ 'email': 'email address associated with Cloudflare account',
+ 'api-key': 'API key for Cloudflare account, obtained from {0}'.format(ACCOUNT_URL)
+ }
+ )
+
+ def _perform(self, domain, validation_name, validation):
+ self._get_cloudflare_client().add_txt_record(domain, validation_name, validation, self.ttl)
+
+ def _cleanup(self, domain, validation_name, validation):
+ self._get_cloudflare_client().del_txt_record(domain, validation_name, validation)
+
+ def _get_cloudflare_client(self):
+ return _CloudflareClient(self.credentials.conf('email'), self.credentials.conf('api-key'))
+
+
+class _CloudflareClient(object):
+ """
+ Encapsulates all communication with the Cloudflare API.
+ """
+
+ def __init__(self, email, api_key):
+ self.cf = CloudFlare.CloudFlare(email, api_key)
+
+ def add_txt_record(self, domain, record_name, record_content, record_ttl):
+ """
+ Add a TXT record using the supplied information.
+
+ :param str domain: The domain to use to look up the Cloudflare zone.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ :param int record_ttl: The record TTL (number of seconds that the record may be cached).
+ :raises certbot.errors.PluginError: if an error occurs communicating with the Cloudflare API
+ """
+
+ zone_id = self._find_zone_id(domain)
+
+ data = {'type': 'TXT',
+ 'name': record_name,
+ 'content': record_content,
+ 'ttl': record_ttl}
+
+ try:
+ logger.debug('Attempting to add record to zone %s: %s', zone_id, data)
+ self.cf.zones.dns_records.post(zone_id, data=data) # zones | pylint: disable=no-member
+ except CloudFlare.exceptions.CloudFlareAPIError as e:
+ logger.error('Encountered CloudFlareAPIError adding TXT record: %d %s', e, e)
+ raise errors.PluginError('Error communicating with the Cloudflare API: {0}'.format(e))
+
+ record_id = self._find_txt_record_id(zone_id, record_name, record_content)
+ logger.debug('Successfully added TXT record with record_id: %s', record_id)
+
+ def del_txt_record(self, domain, record_name, record_content):
+ """
+ Delete a TXT record using the supplied information.
+
+ Note that both the record's name and content are used to ensure that similar records
+ created concurrently (e.g., due to concurrent invocations of this plugin) are not deleted.
+
+ Failures are logged, but not raised.
+
+ :param str domain: The domain to use to look up the Cloudflare zone.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ """
+
+ try:
+ zone_id = self._find_zone_id(domain)
+ except errors.PluginError as e:
+ logger.debug('Encountered error finding zone_id during deletion: %s', e)
+ return
+
+ if zone_id:
+ record_id = self._find_txt_record_id(zone_id, record_name, record_content)
+ if record_id:
+ try:
+ # zones | pylint: disable=no-member
+ self.cf.zones.dns_records.delete(zone_id, record_id)
+ logger.debug('Successfully deleted TXT record.')
+ except CloudFlare.exceptions.CloudFlareAPIError as e:
+ logger.warn('Encountered CloudFlareAPIError deleting TXT record: %s', e)
+ else:
+ logger.debug('TXT record not found; no cleanup needed.')
+ else:
+ logger.debug('Zone not found; no cleanup needed.')
+
+ def _find_zone_id(self, domain):
+ """
+ Find the zone_id for a given domain.
+
+ :param str domain: The domain for which to find the zone_id.
+ :returns: The zone_id, if found.
+ :rtype: str
+ :raises certbot.errors.PluginError: if no zone_id is found.
+ """
+
+ zone_name_guesses = dns_common.base_domain_name_guesses(domain)
+
+ for zone_name in zone_name_guesses:
+ params = {'name': zone_name,
+ 'per_page': 1}
+
+ try:
+ zones = self.cf.zones.get(params=params) # zones | pylint: disable=no-member
+ except CloudFlare.exceptions.CloudFlareAPIError as e:
+ code = int(e)
+ hint = None
+
+ if code == 6003:
+ hint = 'Did you copy your entire API key?'
+ elif code == 9103:
+ hint = 'Did you enter the correct email address?'
+
+ raise errors.PluginError('Error determining zone_id: {0} {1}. Please confirm that '
+ 'you have supplied valid Cloudflare API credentials.{2}'
+ .format(code, e, ' ({0})'.format(hint) if hint else ''))
+
+ if len(zones) > 0:
+ zone_id = zones[0]['id']
+ logger.debug('Found zone_id of %s for %s using name %s', zone_id, domain, zone_name)
+ return zone_id
+
+ raise errors.PluginError('Unable to determine zone_id for {0} using zone names: {1}. '
+ 'Please confirm that the domain name has been entered correctly '
+ 'and is already associated with the supplied Cloudflare account.'
+ .format(domain, zone_name_guesses))
+
+ def _find_txt_record_id(self, zone_id, record_name, record_content):
+ """
+ Find the record_id for a TXT record with the given name and content.
+
+ :param str zone_id: The zone_id which contains the record.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ :returns: The record_id, if found.
+ :rtype: str
+ """
+
+ params = {'type': 'TXT',
+ 'name': record_name,
+ 'content': record_content,
+ 'per_page': 1}
+ try:
+ # zones | pylint: disable=no-member
+ records = self.cf.zones.dns_records.get(zone_id, params=params)
+ except CloudFlare.exceptions.CloudFlareAPIError as e:
+ logger.debug('Encountered CloudFlareAPIError getting TXT record_id: %s', e)
+ records = []
+
+ if len(records) > 0:
+ # Cleanup is returning the system to the state we found it. If, for some reason,
+ # there are multiple matching records, we only delete one because we only added one.
+ return records[0]['id']
+ else:
+ logger.debug('Unable to find TXT record.')
diff --git a/certbot-dns-cloudflare/certbot_dns_cloudflare/dns_cloudflare_test.py b/certbot-dns-cloudflare/certbot_dns_cloudflare/dns_cloudflare_test.py
new file mode 100644
index 000000000..e60d6ff8b
--- /dev/null
+++ b/certbot-dns-cloudflare/certbot_dns_cloudflare/dns_cloudflare_test.py
@@ -0,0 +1,174 @@
+"""Tests for certbot_dns_cloudflare.dns_cloudflare."""
+
+import os
+import unittest
+
+import CloudFlare
+import mock
+
+from certbot import errors
+from certbot.plugins import dns_test_common
+from certbot.plugins.dns_test_common import DOMAIN
+from certbot.tests import util as test_util
+
+API_ERROR = CloudFlare.exceptions.CloudFlareAPIError(1000, '', '')
+API_KEY = 'an-api-key'
+EMAIL = 'example@example.com'
+
+
+class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest):
+
+ def setUp(self):
+ from certbot_dns_cloudflare.dns_cloudflare import Authenticator
+
+ super(AuthenticatorTest, self).setUp()
+
+ path = os.path.join(self.tempdir, 'file.ini')
+ dns_test_common.write({"cloudflare_email": EMAIL, "cloudflare_api_key": API_KEY}, path)
+
+ self.config = mock.MagicMock(cloudflare_credentials=path,
+ cloudflare_propagation_seconds=0) # don't wait during tests
+
+ self.auth = Authenticator(self.config, "cloudflare")
+
+ self.mock_client = mock.MagicMock()
+ # _get_cloudflare_client | pylint: disable=protected-access
+ self.auth._get_cloudflare_client = mock.MagicMock(return_value=self.mock_client)
+
+ def test_perform(self):
+ self.auth.perform([self.achall])
+
+ expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+ def test_cleanup(self):
+ # _attempt_cleanup | pylint: disable=protected-access
+ self.auth._attempt_cleanup = True
+ self.auth.cleanup([self.achall])
+
+ expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+
+class CloudflareClientTest(unittest.TestCase):
+ record_name = "foo"
+ record_content = "bar"
+ record_ttl = 42
+ zone_id = 1
+ record_id = 2
+
+ def setUp(self):
+ from certbot_dns_cloudflare.dns_cloudflare import _CloudflareClient
+
+ self.cloudflare_client = _CloudflareClient(EMAIL, API_KEY)
+
+ self.cf = mock.MagicMock()
+ self.cloudflare_client.cf = self.cf
+
+ def test_add_txt_record(self):
+ self.cf.zones.get.return_value = [{'id': self.zone_id}]
+
+ self.cloudflare_client.add_txt_record(DOMAIN, self.record_name, self.record_content,
+ self.record_ttl)
+
+ self.cf.zones.dns_records.post.assert_called_with(self.zone_id, data=mock.ANY)
+
+ post_data = self.cf.zones.dns_records.post.call_args[1]['data']
+
+ self.assertEqual('TXT', post_data['type'])
+ self.assertEqual(self.record_name, post_data['name'])
+ self.assertEqual(self.record_content, post_data['content'])
+ self.assertEqual(self.record_ttl, post_data['ttl'])
+
+ def test_add_txt_record_error(self):
+ self.cf.zones.get.return_value = [{'id': self.zone_id}]
+
+ self.cf.zones.dns_records.post.side_effect = API_ERROR
+
+ self.assertRaises(
+ errors.PluginError,
+ self.cloudflare_client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ def test_add_txt_record_error_during_zone_lookup(self):
+ self.cf.zones.get.side_effect = API_ERROR
+
+ self.assertRaises(
+ errors.PluginError,
+ self.cloudflare_client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ def test_add_txt_record_zone_not_found(self):
+ self.cf.zones.get.return_value = []
+
+ self.assertRaises(
+ errors.PluginError,
+ self.cloudflare_client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ def test_del_txt_record(self):
+ self.cf.zones.get.return_value = [{'id': self.zone_id}]
+ self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}]
+
+ self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ expected = [mock.call.zones.get(params=mock.ANY),
+ mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY),
+ mock.call.zones.dns_records.delete(self.zone_id, self.record_id)]
+
+ self.assertEqual(expected, self.cf.mock_calls)
+
+ get_data = self.cf.zones.dns_records.get.call_args[1]['params']
+
+ self.assertEqual('TXT', get_data['type'])
+ self.assertEqual(self.record_name, get_data['name'])
+ self.assertEqual(self.record_content, get_data['content'])
+
+ def test_del_txt_record_error_during_zone_lookup(self):
+ self.cf.zones.get.side_effect = API_ERROR
+
+ self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record_error_during_delete(self):
+ self.cf.zones.get.return_value = [{'id': self.zone_id}]
+ self.cf.zones.dns_records.get.return_value = [{'id': self.record_id}]
+ self.cf.zones.dns_records.delete.side_effect = API_ERROR
+
+ self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+ expected = [mock.call.zones.get(params=mock.ANY),
+ mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY),
+ mock.call.zones.dns_records.delete(self.zone_id, self.record_id)]
+
+ self.assertEqual(expected, self.cf.mock_calls)
+
+ def test_del_txt_record_error_during_get(self):
+ self.cf.zones.get.return_value = [{'id': self.zone_id}]
+ self.cf.zones.dns_records.get.side_effect = API_ERROR
+
+ self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+ expected = [mock.call.zones.get(params=mock.ANY),
+ mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)]
+
+ self.assertEqual(expected, self.cf.mock_calls)
+
+ def test_del_txt_record_no_record(self):
+ self.cf.zones.get.return_value = [{'id': self.zone_id}]
+ self.cf.zones.dns_records.get.return_value = []
+
+ self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+ expected = [mock.call.zones.get(params=mock.ANY),
+ mock.call.zones.dns_records.get(self.zone_id, params=mock.ANY)]
+
+ self.assertEqual(expected, self.cf.mock_calls)
+
+ def test_del_txt_record_no_zone(self):
+ self.cf.zones.get.return_value = [{'id': None}]
+
+ self.cloudflare_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+ expected = [mock.call.zones.get(params=mock.ANY)]
+
+ self.assertEqual(expected, self.cf.mock_calls)
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot-dns-cloudflare/docs/.gitignore b/certbot-dns-cloudflare/docs/.gitignore
new file mode 100644
index 000000000..ba65b13af
--- /dev/null
+++ b/certbot-dns-cloudflare/docs/.gitignore
@@ -0,0 +1 @@
+/_build/
diff --git a/certbot-dns-cloudflare/docs/Makefile b/certbot-dns-cloudflare/docs/Makefile
new file mode 100644
index 000000000..091abbfe7
--- /dev/null
+++ b/certbot-dns-cloudflare/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+SPHINXPROJ = certbot-dns-cloudflare
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
diff --git a/certbot-dns-cloudflare/docs/api.rst b/certbot-dns-cloudflare/docs/api.rst
new file mode 100644
index 000000000..8668ec5d8
--- /dev/null
+++ b/certbot-dns-cloudflare/docs/api.rst
@@ -0,0 +1,8 @@
+=================
+API Documentation
+=================
+
+.. toctree::
+ :glob:
+
+ api/**
diff --git a/certbot-dns-cloudflare/docs/api/dns_cloudflare.rst b/certbot-dns-cloudflare/docs/api/dns_cloudflare.rst
new file mode 100644
index 000000000..939d4c0b4
--- /dev/null
+++ b/certbot-dns-cloudflare/docs/api/dns_cloudflare.rst
@@ -0,0 +1,5 @@
+:mod:`certbot_dns_cloudflare.dns_cloudflare`
+--------------------------------------
+
+.. automodule:: certbot_dns_cloudflare.dns_cloudflare
+ :members:
diff --git a/certbot-dns-cloudflare/docs/conf.py b/certbot-dns-cloudflare/docs/conf.py
new file mode 100644
index 000000000..aa7809246
--- /dev/null
+++ b/certbot-dns-cloudflare/docs/conf.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+#
+# certbot-dns-cloudflare documentation build configuration file, created by
+# sphinx-quickstart on Tue May 9 10:20:04 2017.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinx.ext.autodoc',
+ 'sphinx.ext.intersphinx',
+ 'sphinx.ext.todo',
+ 'sphinx.ext.coverage',
+ 'sphinx.ext.viewcode']
+
+autodoc_member_order = 'bysource'
+autodoc_default_flags = ['show-inheritance', 'private-members']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'certbot-dns-cloudflare'
+copyright = u'2017, Certbot Project'
+author = u'Certbot Project'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = u'0'
+# The full version, including alpha/beta/rc tags.
+release = u'0'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+default_role = 'py:obj'
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = True
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+
+# http://docs.readthedocs.org/en/latest/theme.html#how-do-i-use-this-locally-and-on-read-the-docs
+# on_rtd is whether we are on readthedocs.org
+on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+if not on_rtd: # only import and set the theme if we're building docs locally
+ import sphinx_rtd_theme
+ html_theme = 'sphinx_rtd_theme'
+ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
+# otherwise, readthedocs.org uses their theme by default, so no need to specify it
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'certbot-dns-cloudflaredoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+
+ # 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, 'certbot-dns-cloudflare.tex', u'certbot-dns-cloudflare Documentation',
+ u'Certbot Project', 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ (master_doc, 'certbot-dns-cloudflare', u'certbot-dns-cloudflare Documentation',
+ [author], 1)
+]
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ (master_doc, 'certbot-dns-cloudflare', u'certbot-dns-cloudflare Documentation',
+ author, 'certbot-dns-cloudflare', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {
+ 'python': ('https://docs.python.org/', None),
+ 'acme': ('https://acme-python.readthedocs.org/en/latest/', None),
+ 'certbot': ('https://certbot.eff.org/docs/', None),
+}
diff --git a/certbot-dns-cloudflare/docs/index.rst b/certbot-dns-cloudflare/docs/index.rst
new file mode 100644
index 000000000..e75106a01
--- /dev/null
+++ b/certbot-dns-cloudflare/docs/index.rst
@@ -0,0 +1,27 @@
+.. certbot-dns-cloudflare documentation master file, created by
+ sphinx-quickstart on Tue May 9 10:20:04 2017.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to certbot-dns-cloudflare's documentation!
+==================================================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+.. toctree::
+ :maxdepth: 1
+
+ api
+
+.. automodule:: certbot_dns_cloudflare
+ :members:
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/certbot-dns-cloudflare/docs/make.bat b/certbot-dns-cloudflare/docs/make.bat
new file mode 100644
index 000000000..88867c770
--- /dev/null
+++ b/certbot-dns-cloudflare/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=certbot-dns-cloudflare
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/certbot-dns-cloudflare/setup.cfg b/certbot-dns-cloudflare/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-dns-cloudflare/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-dns-cloudflare/setup.py b/certbot-dns-cloudflare/setup.py
new file mode 100644
index 000000000..26570a0ff
--- /dev/null
+++ b/certbot-dns-cloudflare/setup.py
@@ -0,0 +1,69 @@
+import sys
+
+from setuptools import setup
+from setuptools import find_packages
+
+
+version = '0.15.0.dev0'
+
+# Please update tox.ini when modifying dependency version requirements
+install_requires = [
+ 'acme=={0}'.format(version),
+ 'certbot=={0}'.format(version),
+ 'cloudflare>=1.5.1',
+ 'mock',
+ # For pkg_resources. >=1.0 so pip resolves it to a version cryptography
+ # will tolerate; see #2599:
+ 'setuptools>=1.0',
+ 'zope.interface',
+]
+
+docs_extras = [
+ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
+ 'sphinx_rtd_theme',
+]
+
+setup(
+ name='certbot-dns-cloudflare',
+ version=version,
+ description="Cloudflare DNS Authenticator plugin for Certbot",
+ url='https://github.com/certbot/certbot',
+ author="Certbot Project",
+ author_email='client-dev@letsencrypt.org',
+ license='Apache License 2.0',
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Plugins',
+ 'Intended Audience :: System Administrators',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: POSIX :: Linux',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.6',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
+ 'Topic :: Internet :: WWW/HTTP',
+ 'Topic :: Security',
+ 'Topic :: System :: Installation/Setup',
+ 'Topic :: System :: Networking',
+ 'Topic :: System :: Systems Administration',
+ 'Topic :: Utilities',
+ ],
+
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=install_requires,
+ extras_require={
+ 'docs': docs_extras,
+ },
+ entry_points={
+ 'certbot.plugins': [
+ 'dns-cloudflare = certbot_dns_cloudflare.dns_cloudflare:Authenticator',
+ ],
+ },
+ test_suite='certbot_dns_cloudflare',
+)
diff --git a/certbot-dns-cloudxns/LICENSE.txt b/certbot-dns-cloudxns/LICENSE.txt
new file mode 100644
index 000000000..981c46c9f
--- /dev/null
+++ b/certbot-dns-cloudxns/LICENSE.txt
@@ -0,0 +1,190 @@
+ Copyright 2015 Electronic Frontier Foundation and others
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/certbot-dns-cloudxns/MANIFEST.in b/certbot-dns-cloudxns/MANIFEST.in
new file mode 100644
index 000000000..18f018c08
--- /dev/null
+++ b/certbot-dns-cloudxns/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE.txt
+include README.rst
+recursive-include docs *
diff --git a/certbot-dns-cloudxns/README.rst b/certbot-dns-cloudxns/README.rst
new file mode 100644
index 000000000..b127770df
--- /dev/null
+++ b/certbot-dns-cloudxns/README.rst
@@ -0,0 +1 @@
+CloudXNS DNS Authenticator plugin for Certbot
diff --git a/certbot-dns-cloudxns/certbot_dns_cloudxns/__init__.py b/certbot-dns-cloudxns/certbot_dns_cloudxns/__init__.py
new file mode 100644
index 000000000..8df02d0fa
--- /dev/null
+++ b/certbot-dns-cloudxns/certbot_dns_cloudxns/__init__.py
@@ -0,0 +1 @@
+"""CloudXNS DNS Authenticator"""
diff --git a/certbot-dns-cloudxns/certbot_dns_cloudxns/dns_cloudxns.py b/certbot-dns-cloudxns/certbot_dns_cloudxns/dns_cloudxns.py
new file mode 100644
index 000000000..2e9d23a88
--- /dev/null
+++ b/certbot-dns-cloudxns/certbot_dns_cloudxns/dns_cloudxns.py
@@ -0,0 +1,84 @@
+"""DNS Authenticator for CloudXNS DNS."""
+import logging
+
+import zope.interface
+from lexicon.providers import cloudxns
+
+from certbot import errors
+from certbot import interfaces
+from certbot.plugins import dns_common
+from certbot.plugins import dns_common_lexicon
+
+logger = logging.getLogger(__name__)
+
+ACCOUNT_URL = 'https://www.cloudxns.net/en/AccountManage/apimanage.html'
+
+
+@zope.interface.implementer(interfaces.IAuthenticator)
+@zope.interface.provider(interfaces.IPluginFactory)
+class Authenticator(dns_common.DNSAuthenticator):
+ """DNS Authenticator for CloudXNS DNS
+
+ This Authenticator uses the CloudXNS DNS API to fulfill a dns-01 challenge.
+ """
+
+ description = 'Obtain certs using a DNS TXT record (if you are using CloudXNS for DNS).'
+ ttl = 60
+
+ def __init__(self, *args, **kwargs):
+ super(Authenticator, self).__init__(*args, **kwargs)
+ self.credentials = None
+
+ @classmethod
+ def add_parser_arguments(cls, add): # pylint: disable=arguments-differ
+ super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=30)
+ add('credentials', help='CloudXNS credentials INI file.')
+
+ def more_info(self): # pylint: disable=missing-docstring,no-self-use
+ return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
+ 'the CloudXNS API.'
+
+ def _setup_credentials(self):
+ self.credentials = self._configure_credentials(
+ 'credentials',
+ 'CloudXNS credentials INI file',
+ {
+ 'api-key': 'API key for CloudXNS account, obtained from {0}'.format(ACCOUNT_URL),
+ 'secret-key': 'Secret key for CloudXNS account, obtained from {0}'
+ .format(ACCOUNT_URL)
+ }
+ )
+
+ def _perform(self, domain, validation_name, validation):
+ self._get_cloudxns_client().add_txt_record(domain, validation_name, validation)
+
+ def _cleanup(self, domain, validation_name, validation):
+ self._get_cloudxns_client().del_txt_record(domain, validation_name, validation)
+
+ def _get_cloudxns_client(self):
+ return _CloudXNSLexiconClient(self.credentials.conf('api-key'),
+ self.credentials.conf('secret-key'),
+ self.ttl)
+
+
+class _CloudXNSLexiconClient(dns_common_lexicon.LexiconClient):
+ """
+ Encapsulates all communication with the CloudXNS via Lexicon.
+ """
+
+ def __init__(self, api_key, secret_key, ttl):
+ super(_CloudXNSLexiconClient, self).__init__()
+
+ self.provider = cloudxns.Provider({
+ 'auth_username': api_key,
+ 'auth_token': secret_key,
+ 'ttl': ttl,
+ })
+
+ def _handle_http_error(self, e, domain_name):
+ hint = None
+ if str(e).startswith('400 Client Error:'):
+ hint = 'Are your API key and Secret key values correct?'
+
+ return errors.PluginError('Error determining zone identifier for {0}: {1}.{2}'
+ .format(domain_name, e, ' ({0})'.format(hint) if hint else ''))
diff --git a/certbot-dns-cloudxns/certbot_dns_cloudxns/dns_cloudxns_test.py b/certbot-dns-cloudxns/certbot_dns_cloudxns/dns_cloudxns_test.py
new file mode 100644
index 000000000..c9bad23ab
--- /dev/null
+++ b/certbot-dns-cloudxns/certbot_dns_cloudxns/dns_cloudxns_test.py
@@ -0,0 +1,54 @@
+"""Tests for certbot_dns_cloudxns.dns_cloudxns."""
+
+import os
+import unittest
+
+import mock
+from requests.exceptions import HTTPError, RequestException
+
+from certbot.plugins import dns_test_common
+from certbot.plugins import dns_test_common_lexicon
+from certbot.tests import util as test_util
+
+DOMAIN_NOT_FOUND = Exception('No domain found')
+GENERIC_ERROR = RequestException
+LOGIN_ERROR = HTTPError('400 Client Error: ...')
+
+API_KEY = 'foo'
+SECRET = 'bar'
+
+
+class AuthenticatorTest(test_util.TempDirTestCase,
+ dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
+
+ def setUp(self):
+ super(AuthenticatorTest, self).setUp()
+
+ from certbot_dns_cloudxns.dns_cloudxns import Authenticator
+
+ path = os.path.join(self.tempdir, 'file.ini')
+ dns_test_common.write({"cloudxns_api_key": API_KEY, "cloudxns_secret_key": SECRET}, path)
+
+ self.config = mock.MagicMock(cloudxns_credentials=path,
+ cloudxns_propagation_seconds=0) # don't wait during tests
+
+ self.auth = Authenticator(self.config, "cloudxns")
+
+ self.mock_client = mock.MagicMock()
+ # _get_cloudxns_client | pylint: disable=protected-access
+ self.auth._get_cloudxns_client = mock.MagicMock(return_value=self.mock_client)
+
+
+class CloudXNSLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
+
+ def setUp(self):
+ from certbot_dns_cloudxns.dns_cloudxns import _CloudXNSLexiconClient
+
+ self.client = _CloudXNSLexiconClient(API_KEY, SECRET, 0)
+
+ self.provider_mock = mock.MagicMock()
+ self.client.provider = self.provider_mock
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot-dns-cloudxns/docs/.gitignore b/certbot-dns-cloudxns/docs/.gitignore
new file mode 100644
index 000000000..ba65b13af
--- /dev/null
+++ b/certbot-dns-cloudxns/docs/.gitignore
@@ -0,0 +1 @@
+/_build/
diff --git a/certbot-dns-cloudxns/docs/Makefile b/certbot-dns-cloudxns/docs/Makefile
new file mode 100644
index 000000000..ecda13dfe
--- /dev/null
+++ b/certbot-dns-cloudxns/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+SPHINXPROJ = certbot-dns-cloudxns
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/certbot-dns-cloudxns/docs/api.rst b/certbot-dns-cloudxns/docs/api.rst
new file mode 100644
index 000000000..8668ec5d8
--- /dev/null
+++ b/certbot-dns-cloudxns/docs/api.rst
@@ -0,0 +1,8 @@
+=================
+API Documentation
+=================
+
+.. toctree::
+ :glob:
+
+ api/**
diff --git a/certbot-dns-cloudxns/docs/api/dns_cloudxns.rst b/certbot-dns-cloudxns/docs/api/dns_cloudxns.rst
new file mode 100644
index 000000000..be794d1a0
--- /dev/null
+++ b/certbot-dns-cloudxns/docs/api/dns_cloudxns.rst
@@ -0,0 +1,5 @@
+:mod:`certbot_dns_cloudxns.dns_cloudxns`
+----------------------------------------
+
+.. automodule:: certbot_dns_cloudxns.dns_cloudxns
+ :members:
diff --git a/certbot-dns-cloudxns/docs/conf.py b/certbot-dns-cloudxns/docs/conf.py
new file mode 100644
index 000000000..9e2f4c0e6
--- /dev/null
+++ b/certbot-dns-cloudxns/docs/conf.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+#
+# certbot-dns-cloudxns documentation build configuration file, created by
+# sphinx-quickstart on Wed May 10 16:05:50 2017.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinx.ext.autodoc',
+ 'sphinx.ext.intersphinx',
+ 'sphinx.ext.todo',
+ 'sphinx.ext.coverage',
+ 'sphinx.ext.viewcode']
+
+autodoc_member_order = 'bysource'
+autodoc_default_flags = ['show-inheritance', 'private-members']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'certbot-dns-cloudxns'
+copyright = u'2017, Certbot Project'
+author = u'Certbot Project'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = u'0'
+# The full version, including alpha/beta/rc tags.
+release = u'0'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+default_role = 'py:obj'
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = True
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+
+# http://docs.readthedocs.org/en/latest/theme.html#how-do-i-use-this-locally-and-on-read-the-docs
+# on_rtd is whether we are on readthedocs.org
+on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+if not on_rtd: # only import and set the theme if we're building docs locally
+ import sphinx_rtd_theme
+ html_theme = 'sphinx_rtd_theme'
+ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
+# otherwise, readthedocs.org uses their theme by default, so no need to specify it
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'certbot-dns-cloudxnsdoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+
+ # 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, 'certbot-dns-cloudxns.tex', u'certbot-dns-cloudxns Documentation',
+ u'Certbot Project', 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ (master_doc, 'certbot-dns-cloudxns', u'certbot-dns-cloudxns Documentation',
+ [author], 1)
+]
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ (master_doc, 'certbot-dns-cloudxns', u'certbot-dns-cloudxns Documentation',
+ author, 'certbot-dns-cloudxns', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {
+ 'python': ('https://docs.python.org/', None),
+ 'acme': ('https://acme-python.readthedocs.org/en/latest/', None),
+ 'certbot': ('https://certbot.eff.org/docs/', None),
+}
diff --git a/certbot-dns-cloudxns/docs/index.rst b/certbot-dns-cloudxns/docs/index.rst
new file mode 100644
index 000000000..41ea250cd
--- /dev/null
+++ b/certbot-dns-cloudxns/docs/index.rst
@@ -0,0 +1,28 @@
+.. certbot-dns-cloudxns documentation master file, created by
+ sphinx-quickstart on Wed May 10 16:05:50 2017.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to certbot-dns-cloudxns's documentation!
+================================================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+.. toctree::
+ :maxdepth: 1
+
+ api
+
+.. automodule:: certbot_dns_cloudxns
+ :members:
+
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/certbot-dns-cloudxns/docs/make.bat b/certbot-dns-cloudxns/docs/make.bat
new file mode 100644
index 000000000..12f4f0de6
--- /dev/null
+++ b/certbot-dns-cloudxns/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=certbot-dns-cloudxns
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/certbot-dns-cloudxns/setup.cfg b/certbot-dns-cloudxns/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-dns-cloudxns/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-dns-cloudxns/setup.py b/certbot-dns-cloudxns/setup.py
new file mode 100644
index 000000000..4849007bc
--- /dev/null
+++ b/certbot-dns-cloudxns/setup.py
@@ -0,0 +1,68 @@
+import sys
+
+from setuptools import setup
+from setuptools import find_packages
+
+
+version = '0.15.0.dev0'
+
+# Please update tox.ini when modifying dependency version requirements
+install_requires = [
+ 'acme=={0}'.format(version),
+ 'certbot=={0}'.format(version),
+ 'dns-lexicon',
+ 'mock',
+ # For pkg_resources. >=1.0 so pip resolves it to a version cryptography
+ # will tolerate; see #2599:
+ 'setuptools>=1.0',
+ 'zope.interface',
+]
+
+docs_extras = [
+ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
+ 'sphinx_rtd_theme',
+]
+
+setup(
+ name='certbot-dns-cloudxns',
+ version=version,
+ description="CloudXNS DNS Authenticator plugin for Certbot",
+ url='https://github.com/certbot/certbot',
+ author="Certbot Project",
+ author_email='client-dev@letsencrypt.org',
+ license='Apache License 2.0',
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Plugins',
+ 'Intended Audience :: System Administrators',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: POSIX :: Linux',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
+ 'Topic :: Internet :: WWW/HTTP',
+ 'Topic :: Security',
+ 'Topic :: System :: Installation/Setup',
+ 'Topic :: System :: Networking',
+ 'Topic :: System :: Systems Administration',
+ 'Topic :: Utilities',
+ ],
+
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=install_requires,
+ extras_require={
+ 'docs': docs_extras,
+ },
+ entry_points={
+ 'certbot.plugins': [
+ 'dns-cloudxns = certbot_dns_cloudxns.dns_cloudxns:Authenticator',
+ ],
+ },
+ test_suite='certbot_dns_cloudxns',
+)
diff --git a/certbot-dns-digitalocean/LICENSE.txt b/certbot-dns-digitalocean/LICENSE.txt
new file mode 100644
index 000000000..981c46c9f
--- /dev/null
+++ b/certbot-dns-digitalocean/LICENSE.txt
@@ -0,0 +1,190 @@
+ Copyright 2015 Electronic Frontier Foundation and others
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/certbot-dns-digitalocean/MANIFEST.in b/certbot-dns-digitalocean/MANIFEST.in
new file mode 100644
index 000000000..18f018c08
--- /dev/null
+++ b/certbot-dns-digitalocean/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE.txt
+include README.rst
+recursive-include docs *
diff --git a/certbot-dns-digitalocean/README.rst b/certbot-dns-digitalocean/README.rst
new file mode 100644
index 000000000..6cccdfeb7
--- /dev/null
+++ b/certbot-dns-digitalocean/README.rst
@@ -0,0 +1 @@
+DigitalOcean DNS Authenticator plugin for Certbot
diff --git a/certbot-dns-digitalocean/certbot_dns_digitalocean/__init__.py b/certbot-dns-digitalocean/certbot_dns_digitalocean/__init__.py
new file mode 100644
index 000000000..40b2527f8
--- /dev/null
+++ b/certbot-dns-digitalocean/certbot_dns_digitalocean/__init__.py
@@ -0,0 +1 @@
+"""DigitalOcean DNS Authenticator"""
diff --git a/certbot-dns-digitalocean/certbot_dns_digitalocean/dns_digitalocean.py b/certbot-dns-digitalocean/certbot_dns_digitalocean/dns_digitalocean.py
new file mode 100644
index 000000000..4bf279279
--- /dev/null
+++ b/certbot-dns-digitalocean/certbot_dns_digitalocean/dns_digitalocean.py
@@ -0,0 +1,168 @@
+"""DNS Authenticator for DigitalOcean."""
+import logging
+
+import digitalocean
+import zope.interface
+
+from certbot import errors
+from certbot import interfaces
+from certbot.plugins import dns_common
+
+logger = logging.getLogger(__name__)
+
+
+@zope.interface.implementer(interfaces.IAuthenticator)
+@zope.interface.provider(interfaces.IPluginFactory)
+class Authenticator(dns_common.DNSAuthenticator):
+ """DNS Authenticator for DigitalOcean
+
+ This Authenticator uses the DigitalOcean API to fulfill a dns-01 challenge.
+ """
+
+ description = 'Obtain certs using a DNS TXT record (if you are using DigitalOcean for DNS).'
+
+ def __init__(self, *args, **kwargs):
+ super(Authenticator, self).__init__(*args, **kwargs)
+ self.credentials = None
+
+ @classmethod
+ def add_parser_arguments(cls, add): # pylint: disable=arguments-differ
+ super(Authenticator, cls).add_parser_arguments(add)
+ add('credentials', help='DigitalOcean credentials INI file.')
+
+ def more_info(self): # pylint: disable=missing-docstring,no-self-use
+ return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
+ 'the DigitalOcean API.'
+
+ def _setup_credentials(self):
+ self.credentials = self._configure_credentials(
+ 'credentials',
+ 'DigitalOcean credentials INI file',
+ {
+ 'token': 'API token for DigitalOcean account'
+ }
+ )
+
+ def _perform(self, domain, validation_name, validation):
+ self._get_digitalocean_client().add_txt_record(domain, validation_name, validation)
+
+ def _cleanup(self, domain, validation_name, validation):
+ self._get_digitalocean_client().del_txt_record(domain, validation_name, validation)
+
+ def _get_digitalocean_client(self):
+ return _DigitalOceanClient(self.credentials.conf('token'))
+
+
+class _DigitalOceanClient(object):
+ """
+ Encapsulates all communication with the DigitalOcean API.
+ """
+
+ def __init__(self, token):
+ self.manager = digitalocean.Manager(token=token)
+
+ def add_txt_record(self, domain_name, record_name, record_content):
+ """
+ Add a TXT record using the supplied information.
+
+ :param str domain_name: The domain to use to associate the record with.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ :raises certbot.errors.PluginError: if an error occurs communicating with the DigitalOcean
+ API
+ """
+
+ try:
+ domain = self._find_domain(domain_name)
+ except digitalocean.Error as e:
+ hint = None
+
+ if str(e).startswith("Unable to authenticate"):
+ hint = 'Did you provide a valid API token?'
+
+ logger.debug('Error finding domain using the DigitalOcean API: %s', e)
+ raise errors.PluginError('Error finding domain using the DigitalOcean API: {0}{1}'
+ .format(e, ' ({0})'.format(hint) if hint else ''))
+
+ try:
+ result = domain.create_new_domain_record(
+ type='TXT',
+ name=self._compute_record_name(domain, record_name),
+ data=record_content)
+
+ record_id = result['domain_record']['id']
+
+ logger.debug('Successfully added TXT record with id: %d', record_id)
+ except digitalocean.Error as e:
+ logger.debug('Error adding TXT record using the DigitalOcean API: %s', e)
+ raise errors.PluginError('Error adding TXT record using the DigitalOcean API: {0}'
+ .format(e))
+
+ def del_txt_record(self, domain_name, record_name, record_content):
+ """
+ Delete a TXT record using the supplied information.
+
+ Note that both the record's name and content are used to ensure that similar records
+ created concurrently (e.g., due to concurrent invocations of this plugin) are not deleted.
+
+ Failures are logged, but not raised.
+
+ :param str domain_name: The domain to use to associate the record with.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ """
+
+ try:
+ domain = self._find_domain(domain_name)
+ except digitalocean.Error as e:
+ logger.debug('Error finding domain using the DigitalOcean API: %s', e)
+ return
+
+ try:
+ domain_records = domain.get_records()
+
+ matching_records = [record for record in domain_records
+ if record.type == 'TXT'
+ and record.name == self._compute_record_name(domain, record_name)
+ and record.data == record_content]
+ except digitalocean.Error as e:
+ logger.debug('Error getting DNS records using the DigitalOcean API: %s', e)
+ return
+
+ for record in matching_records:
+ try:
+ logger.debug('Removing TXT record with id: %s', record.id)
+ record.destroy()
+ except digitalocean.Error as e:
+ logger.warn('Error deleting TXT record %s using the DigitalOcean API: %s',
+ record.id, e)
+
+ def _find_domain(self, domain_name):
+ """
+ Find the domain object for a given domain name.
+
+ :param str domain_name: The domain name for which to find the corresponding Domain.
+ :returns: The Domain, if found.
+ :rtype: `~digitalocean.Domain`
+ :raises certbot.errors.PluginError: if no matching Domain is found.
+ """
+
+ domain_name_guesses = dns_common.base_domain_name_guesses(domain_name)
+
+ domains = self.manager.get_all_domains()
+
+ for guess in domain_name_guesses:
+ matches = [domain for domain in domains if domain.name == guess]
+
+ if len(matches) > 0:
+ domain = matches[0]
+ logger.debug('Found base domain for %s using name %s', domain_name, guess)
+ return domain
+
+ raise errors.PluginError('Unable to determine base domain for {0} using names: {1}.'
+ .format(domain_name, domain_name_guesses))
+
+ @staticmethod
+ def _compute_record_name(domain, full_record_name):
+ # The domain, from DigitalOcean's point of view, is automatically appended.
+ return full_record_name.rpartition("." + domain.name)[0]
diff --git a/certbot-dns-digitalocean/certbot_dns_digitalocean/dns_digitalocean_test.py b/certbot-dns-digitalocean/certbot_dns_digitalocean/dns_digitalocean_test.py
new file mode 100644
index 000000000..7e97eed07
--- /dev/null
+++ b/certbot-dns-digitalocean/certbot_dns_digitalocean/dns_digitalocean_test.py
@@ -0,0 +1,170 @@
+"""Tests for certbot_dns_digitalocean.dns_digitalocean."""
+
+import os
+import unittest
+
+import digitalocean
+import mock
+
+from certbot import errors
+from certbot.plugins import dns_test_common
+from certbot.plugins.dns_test_common import DOMAIN
+from certbot.tests import util as test_util
+
+API_ERROR = digitalocean.DataReadError()
+TOKEN = 'a-token'
+
+
+class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest):
+
+ def setUp(self):
+ from certbot_dns_digitalocean.dns_digitalocean import Authenticator
+
+ super(AuthenticatorTest, self).setUp()
+
+ path = os.path.join(self.tempdir, 'file.ini')
+ dns_test_common.write({"digitalocean_token": TOKEN}, path)
+
+ self.config = mock.MagicMock(digitalocean_credentials=path,
+ digitalocean_propagation_seconds=0) # don't wait during tests
+
+ self.auth = Authenticator(self.config, "digitalocean")
+
+ self.mock_client = mock.MagicMock()
+ # _get_digitalocean_client | pylint: disable=protected-access
+ self.auth._get_digitalocean_client = mock.MagicMock(return_value=self.mock_client)
+
+ def test_perform(self):
+ self.auth.perform([self.achall])
+
+ expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+ def test_cleanup(self):
+ # _attempt_cleanup | pylint: disable=protected-access
+ self.auth._attempt_cleanup = True
+ self.auth.cleanup([self.achall])
+
+ expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+
+class DigitalOceanClientTest(unittest.TestCase):
+ id = 1
+ record_prefix = "_acme-challenge"
+ record_name = record_prefix + "." + DOMAIN
+ record_content = "bar"
+
+ def setUp(self):
+ from certbot_dns_digitalocean.dns_digitalocean import _DigitalOceanClient
+
+ self.digitalocean_client = _DigitalOceanClient(TOKEN)
+
+ self.manager = mock.MagicMock()
+ self.digitalocean_client.manager = self.manager
+
+ def test_add_txt_record(self):
+ wrong_domain_mock = mock.MagicMock()
+ wrong_domain_mock.name = "other.invalid"
+ wrong_domain_mock.create_new_domain_record.side_effect = AssertionError('Wrong Domain')
+
+ domain_mock = mock.MagicMock()
+ domain_mock.name = DOMAIN
+ domain_mock.create_new_domain_record.return_value = {'domain_record': {'id': self.id}}
+
+ self.manager.get_all_domains.return_value = [wrong_domain_mock, domain_mock]
+
+ self.digitalocean_client.add_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ domain_mock.create_new_domain_record.assert_called_with(type='TXT',
+ name=self.record_prefix,
+ data=self.record_content)
+
+ def test_add_txt_record_fail_to_find_domain(self):
+ self.manager.get_all_domains.return_value = []
+
+ self.assertRaises(errors.PluginError,
+ self.digitalocean_client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_add_txt_record_error_finding_domain(self):
+ self.manager.get_all_domains.side_effect = API_ERROR
+
+ self.assertRaises(errors.PluginError,
+ self.digitalocean_client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_add_txt_record_error_creating_record(self):
+ domain_mock = mock.MagicMock()
+ domain_mock.name = DOMAIN
+ domain_mock.create_new_domain_record.side_effect = API_ERROR
+
+ self.manager.get_all_domains.return_value = [domain_mock]
+
+ self.assertRaises(errors.PluginError,
+ self.digitalocean_client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record(self):
+ first_record_mock = mock.MagicMock()
+ first_record_mock.type = 'TXT'
+ first_record_mock.name = "DIFFERENT"
+ first_record_mock.data = self.record_content
+
+ correct_record_mock = mock.MagicMock()
+ correct_record_mock.type = 'TXT'
+ correct_record_mock.name = self.record_prefix
+ correct_record_mock.data = self.record_content
+
+ last_record_mock = mock.MagicMock()
+ last_record_mock.type = 'TXT'
+ last_record_mock.name = self.record_prefix
+ last_record_mock.data = "DIFFERENT"
+
+ domain_mock = mock.MagicMock()
+ domain_mock.name = DOMAIN
+ domain_mock.get_records.return_value = [first_record_mock,
+ correct_record_mock,
+ last_record_mock]
+
+ self.manager.get_all_domains.return_value = [domain_mock]
+
+ self.digitalocean_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ correct_record_mock.destroy.assert_called()
+
+ self.assertItemsEqual(first_record_mock.destroy.call_args_list, [])
+ self.assertItemsEqual(last_record_mock.destroy.call_args_list, [])
+
+ def test_del_txt_record_error_finding_domain(self):
+ self.manager.get_all_domains.side_effect = API_ERROR
+
+ self.digitalocean_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record_error_finding_record(self):
+ domain_mock = mock.MagicMock()
+ domain_mock.name = DOMAIN
+ domain_mock.get_records.side_effect = API_ERROR
+
+ self.manager.get_all_domains.return_value = [domain_mock]
+
+ self.digitalocean_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record_error_deleting_record(self):
+ record_mock = mock.MagicMock()
+ record_mock.type = 'TXT'
+ record_mock.name = self.record_prefix
+ record_mock.data = self.record_content
+ record_mock.destroy.side_effect = API_ERROR
+
+ domain_mock = mock.MagicMock()
+ domain_mock.name = DOMAIN
+ domain_mock.get_records.return_value = [record_mock]
+
+ self.manager.get_all_domains.return_value = [domain_mock]
+
+ self.digitalocean_client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot-dns-digitalocean/docs/.gitignore b/certbot-dns-digitalocean/docs/.gitignore
new file mode 100644
index 000000000..ba65b13af
--- /dev/null
+++ b/certbot-dns-digitalocean/docs/.gitignore
@@ -0,0 +1 @@
+/_build/
diff --git a/certbot-dns-digitalocean/docs/Makefile b/certbot-dns-digitalocean/docs/Makefile
new file mode 100644
index 000000000..701a4fdf9
--- /dev/null
+++ b/certbot-dns-digitalocean/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+SPHINXPROJ = certbot-dns-digitalocean
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/certbot-dns-digitalocean/docs/api.rst b/certbot-dns-digitalocean/docs/api.rst
new file mode 100644
index 000000000..8668ec5d8
--- /dev/null
+++ b/certbot-dns-digitalocean/docs/api.rst
@@ -0,0 +1,8 @@
+=================
+API Documentation
+=================
+
+.. toctree::
+ :glob:
+
+ api/**
diff --git a/certbot-dns-digitalocean/docs/api/dns_digitalocean.rst b/certbot-dns-digitalocean/docs/api/dns_digitalocean.rst
new file mode 100644
index 000000000..8a787987e
--- /dev/null
+++ b/certbot-dns-digitalocean/docs/api/dns_digitalocean.rst
@@ -0,0 +1,5 @@
+:mod:`certbot_dns_digitalocean.dns_digitalocean`
+------------------------------------------------
+
+.. automodule:: certbot_dns_digitalocean.dns_digitalocean
+ :members:
diff --git a/certbot-dns-digitalocean/docs/conf.py b/certbot-dns-digitalocean/docs/conf.py
new file mode 100644
index 000000000..e223b1535
--- /dev/null
+++ b/certbot-dns-digitalocean/docs/conf.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+#
+# certbot-dns-digitalocean documentation build configuration file, created by
+# sphinx-quickstart on Wed May 10 10:52:06 2017.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinx.ext.autodoc',
+ 'sphinx.ext.intersphinx',
+ 'sphinx.ext.todo',
+ 'sphinx.ext.coverage',
+ 'sphinx.ext.viewcode']
+
+autodoc_member_order = 'bysource'
+autodoc_default_flags = ['show-inheritance', 'private-members']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'certbot-dns-digitalocean'
+copyright = u'2017, Certbot Project'
+author = u'Certbot Project'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = u'0'
+# The full version, including alpha/beta/rc tags.
+release = u'0'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+default_role = 'py:obj'
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = True
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+
+# http://docs.readthedocs.org/en/latest/theme.html#how-do-i-use-this-locally-and-on-read-the-docs
+# on_rtd is whether we are on readthedocs.org
+on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+if not on_rtd: # only import and set the theme if we're building docs locally
+ import sphinx_rtd_theme
+ html_theme = 'sphinx_rtd_theme'
+ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
+# otherwise, readthedocs.org uses their theme by default, so no need to specify it
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'certbot-dns-digitaloceandoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+
+ # 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, 'certbot-dns-digitalocean.tex', u'certbot-dns-digitalocean Documentation',
+ u'Certbot Project', 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ (master_doc, 'certbot-dns-digitalocean', u'certbot-dns-digitalocean Documentation',
+ [author], 1)
+]
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ (master_doc, 'certbot-dns-digitalocean', u'certbot-dns-digitalocean Documentation',
+ author, 'certbot-dns-digitalocean', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {
+ 'python': ('https://docs.python.org/', None),
+ 'acme': ('https://acme-python.readthedocs.org/en/latest/', None),
+ 'certbot': ('https://certbot.eff.org/docs/', None),
+}
diff --git a/certbot-dns-digitalocean/docs/index.rst b/certbot-dns-digitalocean/docs/index.rst
new file mode 100644
index 000000000..9f66382ee
--- /dev/null
+++ b/certbot-dns-digitalocean/docs/index.rst
@@ -0,0 +1,28 @@
+.. certbot-dns-digitalocean documentation master file, created by
+ sphinx-quickstart on Wed May 10 10:52:06 2017.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to certbot-dns-digitalocean's documentation!
+====================================================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+.. toctree::
+ :maxdepth: 1
+
+ api
+
+.. automodule:: certbot_dns_digitalocean
+ :members:
+
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/certbot-dns-digitalocean/docs/make.bat b/certbot-dns-digitalocean/docs/make.bat
new file mode 100644
index 000000000..e1bda5e27
--- /dev/null
+++ b/certbot-dns-digitalocean/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=certbot-dns-digitalocean
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/certbot-dns-digitalocean/setup.cfg b/certbot-dns-digitalocean/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-dns-digitalocean/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-dns-digitalocean/setup.py b/certbot-dns-digitalocean/setup.py
new file mode 100644
index 000000000..f76eaa7db
--- /dev/null
+++ b/certbot-dns-digitalocean/setup.py
@@ -0,0 +1,69 @@
+import sys
+
+from setuptools import setup
+from setuptools import find_packages
+
+
+version = '0.15.0.dev0'
+
+# Please update tox.ini when modifying dependency version requirements
+install_requires = [
+ 'acme=={0}'.format(version),
+ 'certbot=={0}'.format(version),
+ 'mock',
+ 'python-digitalocean>=1.11',
+ # For pkg_resources. >=1.0 so pip resolves it to a version cryptography
+ # will tolerate; see #2599:
+ 'setuptools>=1.0',
+ 'zope.interface',
+]
+
+docs_extras = [
+ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
+ 'sphinx_rtd_theme',
+]
+
+setup(
+ name='certbot-dns-digitalocean',
+ version=version,
+ description="DigitalOcean DNS Authenticator plugin for Certbot",
+ url='https://github.com/certbot/certbot',
+ author="Certbot Project",
+ author_email='client-dev@letsencrypt.org',
+ license='Apache License 2.0',
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Plugins',
+ 'Intended Audience :: System Administrators',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: POSIX :: Linux',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.6',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
+ 'Topic :: Internet :: WWW/HTTP',
+ 'Topic :: Security',
+ 'Topic :: System :: Installation/Setup',
+ 'Topic :: System :: Networking',
+ 'Topic :: System :: Systems Administration',
+ 'Topic :: Utilities',
+ ],
+
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=install_requires,
+ extras_require={
+ 'docs': docs_extras,
+ },
+ entry_points={
+ 'certbot.plugins': [
+ 'dns-digitalocean = certbot_dns_digitalocean.dns_digitalocean:Authenticator',
+ ],
+ },
+ test_suite='certbot_dns_digitalocean',
+)
diff --git a/certbot-dns-dnsimple/LICENSE.txt b/certbot-dns-dnsimple/LICENSE.txt
new file mode 100644
index 000000000..981c46c9f
--- /dev/null
+++ b/certbot-dns-dnsimple/LICENSE.txt
@@ -0,0 +1,190 @@
+ Copyright 2015 Electronic Frontier Foundation and others
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/certbot-dns-dnsimple/MANIFEST.in b/certbot-dns-dnsimple/MANIFEST.in
new file mode 100644
index 000000000..18f018c08
--- /dev/null
+++ b/certbot-dns-dnsimple/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE.txt
+include README.rst
+recursive-include docs *
diff --git a/certbot-dns-dnsimple/README.rst b/certbot-dns-dnsimple/README.rst
new file mode 100644
index 000000000..5edb6bf4b
--- /dev/null
+++ b/certbot-dns-dnsimple/README.rst
@@ -0,0 +1 @@
+DNSimple DNS Authenticator plugin for Certbot
diff --git a/certbot-dns-dnsimple/certbot_dns_dnsimple/__init__.py b/certbot-dns-dnsimple/certbot_dns_dnsimple/__init__.py
new file mode 100644
index 000000000..1d6747249
--- /dev/null
+++ b/certbot-dns-dnsimple/certbot_dns_dnsimple/__init__.py
@@ -0,0 +1 @@
+"""DNSimple DNS Authenticator"""
diff --git a/certbot-dns-dnsimple/certbot_dns_dnsimple/dns_dnsimple.py b/certbot-dns-dnsimple/certbot_dns_dnsimple/dns_dnsimple.py
new file mode 100644
index 000000000..f489f889a
--- /dev/null
+++ b/certbot-dns-dnsimple/certbot_dns_dnsimple/dns_dnsimple.py
@@ -0,0 +1,79 @@
+"""DNS Authenticator for DNSimple DNS."""
+import logging
+
+import zope.interface
+from lexicon.providers import dnsimple
+
+from certbot import errors
+from certbot import interfaces
+from certbot.plugins import dns_common
+from certbot.plugins import dns_common_lexicon
+
+logger = logging.getLogger(__name__)
+
+ACCOUNT_URL = 'https://dnsimple.com/user'
+
+
+@zope.interface.implementer(interfaces.IAuthenticator)
+@zope.interface.provider(interfaces.IPluginFactory)
+class Authenticator(dns_common.DNSAuthenticator):
+ """DNS Authenticator for DNSimple
+
+ This Authenticator uses the DNSimple v2 API to fulfill a dns-01 challenge.
+ """
+
+ description = 'Obtain certs using a DNS TXT record (if you are using DNSimple for DNS).'
+ ttl = 60
+
+ def __init__(self, *args, **kwargs):
+ super(Authenticator, self).__init__(*args, **kwargs)
+ self.credentials = None
+
+ @classmethod
+ def add_parser_arguments(cls, add): # pylint: disable=arguments-differ
+ super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=30)
+ add('credentials', help='DNSimple credentials INI file.')
+
+ def more_info(self): # pylint: disable=missing-docstring,no-self-use
+ return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
+ 'the DNSimple API.'
+
+ def _setup_credentials(self):
+ self.credentials = self._configure_credentials(
+ 'credentials',
+ 'DNSimple credentials INI file',
+ {
+ 'token': 'User access token for DNSimple v2 API. (See {0}.)'.format(ACCOUNT_URL)
+ }
+ )
+
+ def _perform(self, domain, validation_name, validation):
+ self._get_dnsimple_client().add_txt_record(domain, validation_name, validation)
+
+ def _cleanup(self, domain, validation_name, validation):
+ self._get_dnsimple_client().del_txt_record(domain, validation_name, validation)
+
+ def _get_dnsimple_client(self):
+ return _DNSimpleLexiconClient(self.credentials.conf('token'), self.ttl)
+
+
+class _DNSimpleLexiconClient(dns_common_lexicon.LexiconClient):
+ """
+ Encapsulates all communication with the DNSimple via Lexicon.
+ """
+
+ def __init__(self, token, ttl):
+ super(_DNSimpleLexiconClient, self).__init__()
+
+ self.provider = dnsimple.Provider({
+ 'auth_token': token,
+ 'ttl': ttl,
+ })
+
+ def _handle_http_error(self, e, domain_name):
+ hint = None
+ if str(e).startswith('401 Client Error: Unauthorized for url:'):
+ hint = 'Is your API token value correct?'
+
+ return errors.PluginError('Error determining zone identifier for {0}: {1}.{2}'
+ .format(domain_name, e, ' ({0})'.format(hint) if hint else ''))
diff --git a/certbot-dns-dnsimple/certbot_dns_dnsimple/dns_dnsimple_test.py b/certbot-dns-dnsimple/certbot_dns_dnsimple/dns_dnsimple_test.py
new file mode 100644
index 000000000..d8f3a23ea
--- /dev/null
+++ b/certbot-dns-dnsimple/certbot_dns_dnsimple/dns_dnsimple_test.py
@@ -0,0 +1,51 @@
+"""Tests for certbot_dns_dnsimple.dns_dnsimple."""
+
+import os
+import unittest
+
+import mock
+from requests.exceptions import HTTPError
+
+from certbot.plugins import dns_test_common
+from certbot.plugins import dns_test_common_lexicon
+from certbot.tests import util as test_util
+
+TOKEN = 'foo'
+
+
+class AuthenticatorTest(test_util.TempDirTestCase,
+ dns_test_common_lexicon.BaseLexiconAuthenticatorTest):
+
+ def setUp(self):
+ super(AuthenticatorTest, self).setUp()
+
+ from certbot_dns_dnsimple.dns_dnsimple import Authenticator
+
+ path = os.path.join(self.tempdir, 'file.ini')
+ dns_test_common.write({"dnsimple_token": TOKEN}, path)
+
+ self.config = mock.MagicMock(dnsimple_credentials=path,
+ dnsimple_propagation_seconds=0) # don't wait during tests
+
+ self.auth = Authenticator(self.config, "dnsimple")
+
+ self.mock_client = mock.MagicMock()
+ # _get_dnsimple_client | pylint: disable=protected-access
+ self.auth._get_dnsimple_client = mock.MagicMock(return_value=self.mock_client)
+
+
+class DNSimpleLexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
+
+ LOGIN_ERROR = HTTPError('401 Client Error: Unauthorized for url: ...')
+
+ def setUp(self):
+ from certbot_dns_dnsimple.dns_dnsimple import _DNSimpleLexiconClient
+
+ self.client = _DNSimpleLexiconClient(TOKEN, 0)
+
+ self.provider_mock = mock.MagicMock()
+ self.client.provider = self.provider_mock
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot-dns-dnsimple/docs/.gitignore b/certbot-dns-dnsimple/docs/.gitignore
new file mode 100644
index 000000000..ba65b13af
--- /dev/null
+++ b/certbot-dns-dnsimple/docs/.gitignore
@@ -0,0 +1 @@
+/_build/
diff --git a/certbot-dns-dnsimple/docs/Makefile b/certbot-dns-dnsimple/docs/Makefile
new file mode 100644
index 000000000..13a07c00d
--- /dev/null
+++ b/certbot-dns-dnsimple/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+SPHINXPROJ = certbot-dns-dnsimple
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/certbot-dns-dnsimple/docs/api.rst b/certbot-dns-dnsimple/docs/api.rst
new file mode 100644
index 000000000..8668ec5d8
--- /dev/null
+++ b/certbot-dns-dnsimple/docs/api.rst
@@ -0,0 +1,8 @@
+=================
+API Documentation
+=================
+
+.. toctree::
+ :glob:
+
+ api/**
diff --git a/certbot-dns-dnsimple/docs/api/dns_dnsimple.rst b/certbot-dns-dnsimple/docs/api/dns_dnsimple.rst
new file mode 100644
index 000000000..b0544107b
--- /dev/null
+++ b/certbot-dns-dnsimple/docs/api/dns_dnsimple.rst
@@ -0,0 +1,5 @@
+:mod:`certbot_dns_dnsimple.dns_dnsimple`
+----------------------------------------
+
+.. automodule:: certbot_dns_dnsimple.dns_dnsimple
+ :members:
diff --git a/certbot-dns-dnsimple/docs/conf.py b/certbot-dns-dnsimple/docs/conf.py
new file mode 100644
index 000000000..da692fb9e
--- /dev/null
+++ b/certbot-dns-dnsimple/docs/conf.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+#
+# certbot-dns-dnsimple documentation build configuration file, created by
+# sphinx-quickstart on Wed May 10 18:23:41 2017.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinx.ext.autodoc',
+ 'sphinx.ext.intersphinx',
+ 'sphinx.ext.todo',
+ 'sphinx.ext.coverage',
+ 'sphinx.ext.viewcode']
+
+autodoc_member_order = 'bysource'
+autodoc_default_flags = ['show-inheritance', 'private-members']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'certbot-dns-dnsimple'
+copyright = u'2017, Certbot Project'
+author = u'Certbot Project'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = u'0'
+# The full version, including alpha/beta/rc tags.
+release = u'0'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+default_role = 'py:obj'
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = True
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+
+# http://docs.readthedocs.org/en/latest/theme.html#how-do-i-use-this-locally-and-on-read-the-docs
+# on_rtd is whether we are on readthedocs.org
+on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+if not on_rtd: # only import and set the theme if we're building docs locally
+ import sphinx_rtd_theme
+ html_theme = 'sphinx_rtd_theme'
+ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
+# otherwise, readthedocs.org uses their theme by default, so no need to specify it
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'certbot-dns-dnsimpledoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+
+ # 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, 'certbot-dns-dnsimple.tex', u'certbot-dns-dnsimple Documentation',
+ u'Certbot Project', 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ (master_doc, 'certbot-dns-dnsimple', u'certbot-dns-dnsimple Documentation',
+ [author], 1)
+]
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ (master_doc, 'certbot-dns-dnsimple', u'certbot-dns-dnsimple Documentation',
+ author, 'certbot-dns-dnsimple', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {
+ 'python': ('https://docs.python.org/', None),
+ 'acme': ('https://acme-python.readthedocs.org/en/latest/', None),
+ 'certbot': ('https://certbot.eff.org/docs/', None),
+}
diff --git a/certbot-dns-dnsimple/docs/index.rst b/certbot-dns-dnsimple/docs/index.rst
new file mode 100644
index 000000000..4ff1e59eb
--- /dev/null
+++ b/certbot-dns-dnsimple/docs/index.rst
@@ -0,0 +1,28 @@
+.. certbot-dns-dnsimple documentation master file, created by
+ sphinx-quickstart on Wed May 10 18:23:41 2017.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to certbot-dns-dnsimple's documentation!
+================================================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+.. toctree::
+ :maxdepth: 1
+
+ api
+
+.. automodule:: certbot_dns_dnsimple
+ :members:
+
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/certbot-dns-dnsimple/docs/make.bat b/certbot-dns-dnsimple/docs/make.bat
new file mode 100644
index 000000000..78e867256
--- /dev/null
+++ b/certbot-dns-dnsimple/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=certbot-dns-dnsimple
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/certbot-dns-dnsimple/setup.cfg b/certbot-dns-dnsimple/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-dns-dnsimple/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-dns-dnsimple/setup.py b/certbot-dns-dnsimple/setup.py
new file mode 100644
index 000000000..bfa89ea25
--- /dev/null
+++ b/certbot-dns-dnsimple/setup.py
@@ -0,0 +1,68 @@
+import sys
+
+from setuptools import setup
+from setuptools import find_packages
+
+
+version = '0.15.0.dev0'
+
+# Please update tox.ini when modifying dependency version requirements
+install_requires = [
+ 'acme=={0}'.format(version),
+ 'certbot=={0}'.format(version),
+ 'dns-lexicon',
+ 'mock',
+ # For pkg_resources. >=1.0 so pip resolves it to a version cryptography
+ # will tolerate; see #2599:
+ 'setuptools>=1.0',
+ 'zope.interface',
+]
+
+docs_extras = [
+ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
+ 'sphinx_rtd_theme',
+]
+
+setup(
+ name='certbot-dns-dnsimple',
+ version=version,
+ description="DNSimple DNS Authenticator plugin for Certbot",
+ url='https://github.com/certbot/certbot',
+ author="Certbot Project",
+ author_email='client-dev@letsencrypt.org',
+ license='Apache License 2.0',
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Plugins',
+ 'Intended Audience :: System Administrators',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: POSIX :: Linux',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
+ 'Topic :: Internet :: WWW/HTTP',
+ 'Topic :: Security',
+ 'Topic :: System :: Installation/Setup',
+ 'Topic :: System :: Networking',
+ 'Topic :: System :: Systems Administration',
+ 'Topic :: Utilities',
+ ],
+
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=install_requires,
+ extras_require={
+ 'docs': docs_extras,
+ },
+ entry_points={
+ 'certbot.plugins': [
+ 'dns-dnsimple = certbot_dns_dnsimple.dns_dnsimple:Authenticator',
+ ],
+ },
+ test_suite='certbot_dns_dnsimple',
+)
diff --git a/certbot-dns-google/LICENSE.txt b/certbot-dns-google/LICENSE.txt
new file mode 100644
index 000000000..981c46c9f
--- /dev/null
+++ b/certbot-dns-google/LICENSE.txt
@@ -0,0 +1,190 @@
+ Copyright 2015 Electronic Frontier Foundation and others
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
diff --git a/certbot-dns-google/MANIFEST.in b/certbot-dns-google/MANIFEST.in
new file mode 100644
index 000000000..18f018c08
--- /dev/null
+++ b/certbot-dns-google/MANIFEST.in
@@ -0,0 +1,3 @@
+include LICENSE.txt
+include README.rst
+recursive-include docs *
diff --git a/certbot-dns-google/README.rst b/certbot-dns-google/README.rst
new file mode 100644
index 000000000..37586abbf
--- /dev/null
+++ b/certbot-dns-google/README.rst
@@ -0,0 +1 @@
+Google Cloud DNS Authenticator plugin for Certbot
diff --git a/certbot-dns-google/certbot_dns_google/__init__.py b/certbot-dns-google/certbot_dns_google/__init__.py
new file mode 100644
index 000000000..9e9096d83
--- /dev/null
+++ b/certbot-dns-google/certbot_dns_google/__init__.py
@@ -0,0 +1 @@
+"""Google Cloud DNS Authenticator"""
diff --git a/certbot-dns-google/certbot_dns_google/dns_google.py b/certbot-dns-google/certbot_dns_google/dns_google.py
new file mode 100644
index 000000000..908c020e1
--- /dev/null
+++ b/certbot-dns-google/certbot_dns_google/dns_google.py
@@ -0,0 +1,184 @@
+"""DNS Authenticator for Google Cloud DNS."""
+import json
+import logging
+
+import zope.interface
+from googleapiclient import discovery
+from googleapiclient import errors as googleapiclient_errors
+from oauth2client.service_account import ServiceAccountCredentials
+
+from certbot import errors
+from certbot import interfaces
+from certbot.plugins import dns_common
+
+logger = logging.getLogger(__name__)
+
+ACCT_URL = 'https://developers.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount'
+PERMISSIONS_URL = 'https://cloud.google.com/dns/access-control#permissions_and_roles'
+
+
+@zope.interface.implementer(interfaces.IAuthenticator)
+@zope.interface.provider(interfaces.IPluginFactory)
+class Authenticator(dns_common.DNSAuthenticator):
+ """DNS Authenticator for Google Cloud DNS
+
+ This Authenticator uses the Google Cloud DNS API to fulfill a dns-01 challenge.
+ """
+
+ description = 'Obtain certs using a DNS TXT record (if you are using Google Cloud DNS for DNS).'
+ ttl = 60
+
+ def __init__(self, *args, **kwargs):
+ super(Authenticator, self).__init__(*args, **kwargs)
+ self.credentials = None
+
+ @classmethod
+ def add_parser_arguments(cls, add): # pylint: disable=arguments-differ
+ super(Authenticator, cls).add_parser_arguments(add, default_propagation_seconds=60)
+ add('credentials',
+ help=('Path to Google Cloud DNS service account JSON file. (See {0} for' +
+ 'information about creating a service account and {1} for information about the' +
+ 'required permissions.)').format(ACCT_URL, PERMISSIONS_URL))
+
+ def more_info(self): # pylint: disable=missing-docstring,no-self-use
+ return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
+ 'the Google Cloud DNS API.'
+
+ def _setup_credentials(self):
+ self._configure_file('credentials', 'path to Google Cloud DNS service account JSON file')
+
+ dns_common.validate_file_permissions(self.conf('credentials'))
+
+ def _perform(self, domain, validation_name, validation):
+ self._get_google_client().add_txt_record(domain, validation_name, validation, self.ttl)
+
+ def _cleanup(self, domain, validation_name, validation):
+ self._get_google_client().del_txt_record(domain, validation_name, validation, self.ttl)
+
+ def _get_google_client(self):
+ return _GoogleClient(self.conf('credentials'))
+
+
+class _GoogleClient(object):
+ """
+ Encapsulates all communication with the Google Cloud DNS API.
+ """
+
+ def __init__(self, account_json):
+
+ scopes = ['https://www.googleapis.com/auth/ndev.clouddns.readwrite']
+ credentials = ServiceAccountCredentials.from_json_keyfile_name(account_json, scopes)
+ self.dns = discovery.build('dns', 'v1', credentials=credentials, cache_discovery=False)
+ with open(account_json) as account:
+ self.project_id = json.load(account)['project_id']
+
+ def add_txt_record(self, domain, record_name, record_content, record_ttl):
+ """
+ Add a TXT record using the supplied information.
+
+ :param str domain: The domain to use to look up the managed zone.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ :param int record_ttl: The record TTL (number of seconds that the record may be cached).
+ :raises certbot.errors.PluginError: if an error occurs communicating with the Google API
+ """
+
+ zone_id = self._find_managed_zone_id(domain)
+
+ data = {
+ "kind": "dns#change",
+ "additions": [
+ {
+ "kind": "dns#resourceRecordSet",
+ "type": "TXT",
+ "name": record_name + ".",
+ "rrdatas": [record_content, ],
+ "ttl": record_ttl,
+ },
+ ],
+ }
+
+ changes = self.dns.changes() # changes | pylint: disable=no-member
+
+ try:
+ request = changes.create(project=self.project_id, managedZone=zone_id, body=data)
+ response = request.execute()
+
+ status = response['status']
+ change = response['id']
+ while status == 'pending':
+ request = changes.get(project=self.project_id, managedZone=zone_id, changeId=change)
+ response = request.execute()
+ status = response['status']
+ except googleapiclient_errors.Error as e:
+ logger.error('Encountered error adding TXT record: %s', e)
+ raise errors.PluginError('Error communicating with the Google Cloud DNS API: {0}'
+ .format(e))
+
+ def del_txt_record(self, domain, record_name, record_content, record_ttl):
+ """
+ Delete a TXT record using the supplied information.
+
+ :param str domain: The domain to use to look up the managed zone.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ :param int record_ttl: The record TTL (number of seconds that the record may be cached).
+ :raises certbot.errors.PluginError: if an error occurs communicating with the Google API
+ """
+
+ try:
+ zone_id = self._find_managed_zone_id(domain)
+ except errors.PluginError as e:
+ logger.warn('Error finding zone. Skipping cleanup.')
+ return
+
+ data = {
+ "kind": "dns#change",
+ "deletions": [
+ {
+ "kind": "dns#resourceRecordSet",
+ "type": "TXT",
+ "name": record_name + ".",
+ "rrdatas": [record_content, ],
+ "ttl": record_ttl,
+ },
+ ],
+ }
+
+ changes = self.dns.changes() # changes | pylint: disable=no-member
+
+ try:
+ request = changes.create(project=self.project_id, managedZone=zone_id, body=data)
+ request.execute()
+ except googleapiclient_errors.Error as e:
+ logger.warn('Encountered error deleting TXT record: %s', e)
+
+ def _find_managed_zone_id(self, domain):
+ """
+ Find the managed zone for a given domain.
+
+ :param str domain: The domain for which to find the managed zone.
+ :returns: The ID of the managed zone, if found.
+ :rtype: str
+ :raises certbot.errors.PluginError: if the managed zone cannot be found.
+ """
+
+ zone_dns_name_guesses = dns_common.base_domain_name_guesses(domain)
+
+ mz = self.dns.managedZones() # managedZones | pylint: disable=no-member
+ for zone_name in zone_dns_name_guesses:
+ try:
+ request = mz.list(project=self.project_id, dnsName=zone_name + '.')
+ response = request.execute()
+ zones = response['managedZones']
+ except googleapiclient_errors.Error as e:
+ raise errors.PluginError('Encountered error finding managed zone: {0}'
+ .format(e))
+
+ if len(zones) > 0:
+ zone_id = zones[0]['id']
+ logger.debug('Found id of %s for %s using name %s', zone_id, domain, zone_name)
+ return zone_id
+
+ raise errors.PluginError('Unable to determine managed zone for {0} using zone names: {1}.'
+ .format(domain, zone_dns_name_guesses))
diff --git a/certbot-dns-google/certbot_dns_google/dns_google_test.py b/certbot-dns-google/certbot_dns_google/dns_google_test.py
new file mode 100644
index 000000000..eb41fa4ee
--- /dev/null
+++ b/certbot-dns-google/certbot_dns_google/dns_google_test.py
@@ -0,0 +1,202 @@
+"""Tests for certbot_dns_google.dns_google."""
+
+import os
+import unittest
+
+import mock
+from googleapiclient.errors import Error
+
+from certbot import errors
+from certbot.plugins import dns_test_common
+from certbot.plugins.dns_test_common import DOMAIN
+from certbot.tests import util as test_util
+
+ACCOUNT_JSON_PATH = '/not/a/real/path.json'
+API_ERROR = Error()
+PROJECT_ID = "test-test-1"
+
+
+class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest):
+
+ def setUp(self):
+ super(AuthenticatorTest, self).setUp()
+
+ from certbot_dns_google.dns_google import Authenticator
+
+ path = os.path.join(self.tempdir, 'file.json')
+ open(path, "wb").close()
+
+ super(AuthenticatorTest, self).setUp()
+ self.config = mock.MagicMock(google_credentials=path,
+ google_propagation_seconds=0) # don't wait during tests
+
+ self.auth = Authenticator(self.config, "google")
+
+ self.mock_client = mock.MagicMock()
+ # _get_google_client | pylint: disable=protected-access
+ self.auth._get_google_client = mock.MagicMock(return_value=self.mock_client)
+
+ def test_perform(self):
+ self.auth.perform([self.achall])
+
+ expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+ def test_cleanup(self):
+ # _attempt_cleanup | pylint: disable=protected-access
+ self.auth._attempt_cleanup = True
+ self.auth.cleanup([self.achall])
+
+ expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+
+class GoogleClientTest(unittest.TestCase):
+ record_name = "foo"
+ record_content = "bar"
+ record_ttl = 42
+ zone = "ZONE_ID"
+ change = "an-id"
+
+ def _setUp_client_with_mock(self, zone_request_side_effect):
+ from certbot_dns_google.dns_google import _GoogleClient
+
+ client = _GoogleClient(ACCOUNT_JSON_PATH)
+
+ # Setup
+ mock_mz = mock.MagicMock()
+ mock_mz.list.return_value.execute.side_effect = zone_request_side_effect
+
+ mock_changes = mock.MagicMock()
+
+ client.dns.managedZones = mock.MagicMock(return_value=mock_mz)
+ client.dns.changes = mock.MagicMock(return_value=mock_changes)
+
+ return client, mock_changes
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_add_txt_record(self, unused_credential_mock):
+ client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
+
+ client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ expected_body = {
+ "kind": "dns#change",
+ "additions": [
+ {
+ "kind": "dns#resourceRecordSet",
+ "type": "TXT",
+ "name": self.record_name + ".",
+ "rrdatas": [self.record_content, ],
+ "ttl": self.record_ttl,
+ },
+ ],
+ }
+
+ changes.create.assert_called_with(body=expected_body,
+ managedZone=self.zone,
+ project=PROJECT_ID)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_add_txt_record_and_poll(self, unused_credential_mock):
+ client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
+ changes.create.return_value.execute.return_value = {'status': 'pending', 'id': self.change}
+ changes.get.return_value.execute.return_value = {'status': 'done'}
+
+ client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ changes.create.assert_called_with(body=mock.ANY,
+ managedZone=self.zone,
+ project=PROJECT_ID)
+
+ changes.get.assert_called_with(changeId=self.change,
+ managedZone=self.zone,
+ project=PROJECT_ID)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_add_txt_record_error_during_zone_lookup(self, unused_credential_mock):
+ client, unused_changes = self._setUp_client_with_mock(API_ERROR)
+
+ self.assertRaises(errors.PluginError, client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_add_txt_record_zone_not_found(self, unused_credential_mock):
+ client, unused_changes = self._setUp_client_with_mock([{'managedZones': []},
+ {'managedZones': []}])
+
+ self.assertRaises(errors.PluginError, client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_add_txt_record_error_during_add(self, unused_credential_mock):
+ client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
+ changes.create.side_effect = API_ERROR
+
+ self.assertRaises(errors.PluginError, client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_del_txt_record(self, unused_credential_mock):
+ client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
+
+ client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ expected_body = {
+ "kind": "dns#change",
+ "deletions": [
+ {
+ "kind": "dns#resourceRecordSet",
+ "type": "TXT",
+ "name": self.record_name + ".",
+ "rrdatas": [self.record_content, ],
+ "ttl": self.record_ttl,
+ },
+ ],
+ }
+
+ changes.create.assert_called_with(body=expected_body,
+ managedZone=self.zone,
+ project=PROJECT_ID)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_del_txt_record_error_during_zone_lookup(self, unused_credential_mock):
+ client, unused_changes = self._setUp_client_with_mock(API_ERROR)
+
+ client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_del_txt_record_zone_not_found(self, unused_credential_mock):
+ client, unused_changes = self._setUp_client_with_mock([{'managedZones': []},
+ {'managedZones': []}])
+
+ client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+ @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
+ @mock.patch('certbot_dns_google.dns_google.open',
+ mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'))
+ def test_del_txt_record_error_during_delete(self, unused_credential_mock):
+ client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
+ changes.create.side_effect = API_ERROR
+
+ client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot-dns-google/docs/.gitignore b/certbot-dns-google/docs/.gitignore
new file mode 100644
index 000000000..ba65b13af
--- /dev/null
+++ b/certbot-dns-google/docs/.gitignore
@@ -0,0 +1 @@
+/_build/
diff --git a/certbot-dns-google/docs/Makefile b/certbot-dns-google/docs/Makefile
new file mode 100644
index 000000000..ea465031b
--- /dev/null
+++ b/certbot-dns-google/docs/Makefile
@@ -0,0 +1,20 @@
+# Minimal makefile for Sphinx documentation
+#
+
+# You can set these variables from the command line.
+SPHINXOPTS =
+SPHINXBUILD = sphinx-build
+SPHINXPROJ = certbot-dns-google
+SOURCEDIR = .
+BUILDDIR = _build
+
+# Put it first so that "make" without argument is like "make help".
+help:
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
+
+.PHONY: help Makefile
+
+# Catch-all target: route all unknown targets to Sphinx using the new
+# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
+%: Makefile
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
\ No newline at end of file
diff --git a/certbot-dns-google/docs/api.rst b/certbot-dns-google/docs/api.rst
new file mode 100644
index 000000000..8668ec5d8
--- /dev/null
+++ b/certbot-dns-google/docs/api.rst
@@ -0,0 +1,8 @@
+=================
+API Documentation
+=================
+
+.. toctree::
+ :glob:
+
+ api/**
diff --git a/certbot-dns-google/docs/api/dns_google.rst b/certbot-dns-google/docs/api/dns_google.rst
new file mode 100644
index 000000000..6f5459934
--- /dev/null
+++ b/certbot-dns-google/docs/api/dns_google.rst
@@ -0,0 +1,5 @@
+:mod:`certbot_dns_google.dns_google`
+------------------------------------
+
+.. automodule:: certbot_dns_google.dns_google
+ :members:
diff --git a/certbot-dns-google/docs/conf.py b/certbot-dns-google/docs/conf.py
new file mode 100644
index 000000000..4ff1af1d1
--- /dev/null
+++ b/certbot-dns-google/docs/conf.py
@@ -0,0 +1,180 @@
+# -*- coding: utf-8 -*-
+#
+# certbot-dns-google documentation build configuration file, created by
+# sphinx-quickstart on Wed May 10 15:47:49 2017.
+#
+# This file is execfile()d with the current directory set to its
+# containing dir.
+#
+# Note that not all possible configuration values are present in this
+# autogenerated file.
+#
+# All configuration values have a default; values that are commented out
+# serve to show the default.
+
+# If extensions (or modules to document with autodoc) are in another directory,
+# add these directories to sys.path here. If the directory is relative to the
+# documentation root, use os.path.abspath to make it absolute, like shown here.
+#
+import os
+# import sys
+# sys.path.insert(0, os.path.abspath('.'))
+
+
+# -- General configuration ------------------------------------------------
+
+# If your documentation needs a minimal Sphinx version, state it here.
+#
+needs_sphinx = '1.0'
+
+# Add any Sphinx extension module names here, as strings. They can be
+# extensions coming with Sphinx (named 'sphinx.ext.*') or your custom
+# ones.
+extensions = ['sphinx.ext.autodoc',
+ 'sphinx.ext.intersphinx',
+ 'sphinx.ext.todo',
+ 'sphinx.ext.coverage',
+ 'sphinx.ext.viewcode']
+
+autodoc_member_order = 'bysource'
+autodoc_default_flags = ['show-inheritance', 'private-members']
+
+# Add any paths that contain templates here, relative to this directory.
+templates_path = ['_templates']
+
+# The suffix(es) of source filenames.
+# You can specify multiple suffix as a list of string:
+#
+# source_suffix = ['.rst', '.md']
+source_suffix = '.rst'
+
+# The master toctree document.
+master_doc = 'index'
+
+# General information about the project.
+project = u'certbot-dns-google'
+copyright = u'2017, Certbot Project'
+author = u'Certbot Project'
+
+# The version info for the project you're documenting, acts as replacement for
+# |version| and |release|, also used in various other places throughout the
+# built documents.
+#
+# The short X.Y version.
+version = u'0'
+# The full version, including alpha/beta/rc tags.
+release = u'0'
+
+# The language for content autogenerated by Sphinx. Refer to documentation
+# for a list of supported languages.
+#
+# This is also used if you do content translation via gettext catalogs.
+# Usually you set "language" from the command line for these cases.
+language = 'en'
+
+# List of patterns, relative to source directory, that match files and
+# directories to ignore when looking for source files.
+# This patterns also effect to html_static_path and html_extra_path
+exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store']
+
+default_role = 'py:obj'
+
+# The name of the Pygments (syntax highlighting) style to use.
+pygments_style = 'sphinx'
+
+# If true, `todo` and `todoList` produce output, else they produce nothing.
+todo_include_todos = True
+
+
+# -- Options for HTML output ----------------------------------------------
+
+# The theme to use for HTML and HTML Help pages. See the documentation for
+# a list of builtin themes.
+#
+
+# http://docs.readthedocs.org/en/latest/theme.html#how-do-i-use-this-locally-and-on-read-the-docs
+# on_rtd is whether we are on readthedocs.org
+on_rtd = os.environ.get('READTHEDOCS', None) == 'True'
+if not on_rtd: # only import and set the theme if we're building docs locally
+ import sphinx_rtd_theme
+ html_theme = 'sphinx_rtd_theme'
+ html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]
+# otherwise, readthedocs.org uses their theme by default, so no need to specify it
+
+# Theme options are theme-specific and customize the look and feel of a theme
+# further. For a list of options available for each theme, see the
+# documentation.
+#
+# html_theme_options = {}
+
+# Add any paths that contain custom static files (such as style sheets) here,
+# relative to this directory. They are copied after the builtin static files,
+# so a file named "default.css" will overwrite the builtin "default.css".
+html_static_path = ['_static']
+
+
+# -- Options for HTMLHelp output ------------------------------------------
+
+# Output file base name for HTML help builder.
+htmlhelp_basename = 'certbot-dns-googledoc'
+
+
+# -- Options for LaTeX output ---------------------------------------------
+
+latex_elements = {
+ # The paper size ('letterpaper' or 'a4paper').
+ #
+ # 'papersize': 'letterpaper',
+
+ # The font size ('10pt', '11pt' or '12pt').
+ #
+ # 'pointsize': '10pt',
+
+ # Additional stuff for the LaTeX preamble.
+ #
+ # 'preamble': '',
+
+ # 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, 'certbot-dns-google.tex', u'certbot-dns-google Documentation',
+ u'Certbot Project', 'manual'),
+]
+
+
+# -- Options for manual page output ---------------------------------------
+
+# One entry per manual page. List of tuples
+# (source start file, name, description, authors, manual section).
+man_pages = [
+ (master_doc, 'certbot-dns-google', u'certbot-dns-google Documentation',
+ [author], 1)
+]
+
+
+# -- Options for Texinfo output -------------------------------------------
+
+# Grouping the document tree into Texinfo files. List of tuples
+# (source start file, target name, title, author,
+# dir menu entry, description, category)
+texinfo_documents = [
+ (master_doc, 'certbot-dns-google', u'certbot-dns-google Documentation',
+ author, 'certbot-dns-google', 'One line description of project.',
+ 'Miscellaneous'),
+]
+
+
+
+
+# Example configuration for intersphinx: refer to the Python standard library.
+intersphinx_mapping = {
+ 'python': ('https://docs.python.org/', None),
+ 'acme': ('https://acme-python.readthedocs.org/en/latest/', None),
+ 'certbot': ('https://certbot.eff.org/docs/', None),
+}
diff --git a/certbot-dns-google/docs/index.rst b/certbot-dns-google/docs/index.rst
new file mode 100644
index 000000000..a8a322f97
--- /dev/null
+++ b/certbot-dns-google/docs/index.rst
@@ -0,0 +1,28 @@
+.. certbot-dns-google documentation master file, created by
+ sphinx-quickstart on Wed May 10 15:47:49 2017.
+ You can adapt this file completely to your liking, but it should at least
+ contain the root `toctree` directive.
+
+Welcome to certbot-dns-google's documentation!
+==============================================
+
+.. toctree::
+ :maxdepth: 2
+ :caption: Contents:
+
+.. toctree::
+ :maxdepth: 1
+
+ api
+
+.. automodule:: certbot_dns_google
+ :members:
+
+
+
+Indices and tables
+==================
+
+* :ref:`genindex`
+* :ref:`modindex`
+* :ref:`search`
diff --git a/certbot-dns-google/docs/make.bat b/certbot-dns-google/docs/make.bat
new file mode 100644
index 000000000..181c12699
--- /dev/null
+++ b/certbot-dns-google/docs/make.bat
@@ -0,0 +1,36 @@
+@ECHO OFF
+
+pushd %~dp0
+
+REM Command file for Sphinx documentation
+
+if "%SPHINXBUILD%" == "" (
+ set SPHINXBUILD=sphinx-build
+)
+set SOURCEDIR=.
+set BUILDDIR=_build
+set SPHINXPROJ=certbot-dns-google
+
+if "%1" == "" goto help
+
+%SPHINXBUILD% >NUL 2>NUL
+if errorlevel 9009 (
+ echo.
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
+ echo.installed, then set the SPHINXBUILD environment variable to point
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
+ echo.may add the Sphinx directory to PATH.
+ echo.
+ echo.If you don't have Sphinx installed, grab it from
+ echo.http://sphinx-doc.org/
+ exit /b 1
+)
+
+%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+goto end
+
+:help
+%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS%
+
+:end
+popd
diff --git a/certbot-dns-google/setup.cfg b/certbot-dns-google/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-dns-google/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-dns-google/setup.py b/certbot-dns-google/setup.py
new file mode 100644
index 000000000..043a9ded1
--- /dev/null
+++ b/certbot-dns-google/setup.py
@@ -0,0 +1,70 @@
+import sys
+
+from setuptools import setup
+from setuptools import find_packages
+
+
+version = '0.15.0.dev0'
+
+# Please update tox.ini when modifying dependency version requirements
+install_requires = [
+ 'acme=={0}'.format(version),
+ 'certbot=={0}'.format(version),
+ 'google-api-python-client',
+ 'mock',
+ 'oauth2client',
+ # For pkg_resources. >=1.0 so pip resolves it to a version cryptography
+ # will tolerate; see #2599:
+ 'setuptools>=1.0',
+ 'zope.interface',
+]
+
+docs_extras = [
+ 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
+ 'sphinx_rtd_theme',
+]
+
+setup(
+ name='certbot-dns-google',
+ version=version,
+ description="Google Cloud DNS Authenticator plugin for Certbot",
+ url='https://github.com/certbot/certbot',
+ author="Certbot Project",
+ author_email='client-dev@letsencrypt.org',
+ license='Apache License 2.0',
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Plugins',
+ 'Intended Audience :: System Administrators',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: POSIX :: Linux',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.6',
+ 'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
+ 'Topic :: Internet :: WWW/HTTP',
+ 'Topic :: Security',
+ 'Topic :: System :: Installation/Setup',
+ 'Topic :: System :: Networking',
+ 'Topic :: System :: Systems Administration',
+ 'Topic :: Utilities',
+ ],
+
+ packages=find_packages(),
+ include_package_data=True,
+ install_requires=install_requires,
+ extras_require={
+ 'docs': docs_extras,
+ },
+ entry_points={
+ 'certbot.plugins': [
+ 'dns-google = certbot_dns_google.dns_google:Authenticator',
+ ],
+ },
+ test_suite='certbot_dns_google',
+)
diff --git a/certbot-nginx/certbot_nginx/configurator.py b/certbot-nginx/certbot_nginx/configurator.py
index afa701a75..752ccc133 100644
--- a/certbot-nginx/certbot_nginx/configurator.py
+++ b/certbot-nginx/certbot_nginx/configurator.py
@@ -139,6 +139,11 @@ class NginxConfigurator(common.Plugin):
"""Full absolute path to SSL configuration file."""
return os.path.join(self.config.config_dir, constants.MOD_SSL_CONF_DEST)
+ @property
+ def updated_mod_ssl_conf_digest(self):
+ """Full absolute path to digest of updated SSL configuration file."""
+ return os.path.join(self.config.config_dir, constants.UPDATED_MOD_SSL_CONF_DIGEST)
+
# This is called in determine_authenticator and determine_installer
def prepare(self):
"""Prepare the authenticator/installer.
@@ -153,15 +158,23 @@ class NginxConfigurator(common.Plugin):
# Make sure configuration is valid
self.config_test()
- # temp_install must be run before creating the NginxParser
- temp_install(self.mod_ssl_conf)
- self.parser = parser.NginxParser(
- self.conf('server-root'), self.mod_ssl_conf)
+
+ self.parser = parser.NginxParser(self.conf('server-root'))
+
+ install_ssl_options_conf(self.mod_ssl_conf, self.updated_mod_ssl_conf_digest)
# Set Version
if self.version is None:
self.version = self.get_version()
+ # Prevent two Nginx plugins from modifying a config at once
+ try:
+ util.lock_dir_until_exit(self.conf('server-root'))
+ except (OSError, errors.LockError):
+ logger.debug('Encountered error:', exc_info=True)
+ raise errors.PluginError(
+ 'Unable to lock %s', self.conf('server-root'))
+
# Entry point in main.py for installing cert
def deploy_cert(self, domain, cert_path, key_path,
chain_path=None, fullchain_path=None):
@@ -444,14 +457,11 @@ class NginxConfigurator(common.Plugin):
snakeoil_cert, snakeoil_key = self._get_snakeoil_paths()
- # the options file doesn't have a newline at the beginning, but there
- # needs to be one when it's dropped into the file
ssl_block = (
[['\n ', 'listen', ' ', '{0} ssl'.format(self.config.tls_sni_01_port)],
['\n ', 'ssl_certificate', ' ', snakeoil_cert],
['\n ', 'ssl_certificate_key', ' ', snakeoil_key],
- ['\n']] +
- self.parser.loc["ssl_options"])
+ ['\n ', 'include', ' ', self.mod_ssl_conf]])
self.parser.add_server_directives(
vhost, ssl_block, replace=False)
@@ -669,7 +679,7 @@ class NginxConfigurator(common.Plugin):
"Configures Nginx to authenticate and install HTTPS.{0}"
"Server root: {root}{0}"
"Version: {version}".format(
- os.linesep, root=self.parser.loc["root"],
+ os.linesep, root=self.parser.config_root,
version=".".join(str(i) for i in self.version))
)
@@ -857,8 +867,38 @@ def nginx_restart(nginx_ctl, nginx_conf):
time.sleep(1)
-def temp_install(options_ssl):
- """Temporary install for convenience."""
+def install_ssl_options_conf(options_ssl, options_ssl_digest):
+ """Copy Certbot's SSL options file into the system's config dir if required."""
+ def _write_current_hash():
+ with open(options_ssl_digest, "w") as f:
+ f.write(constants.CURRENT_SSL_OPTIONS_HASH)
+
+ def _install_current_file():
+ shutil.copyfile(constants.MOD_SSL_CONF_SRC, options_ssl)
+ _write_current_hash()
+
# Check to make sure options-ssl.conf is installed
if not os.path.isfile(options_ssl):
- shutil.copyfile(constants.MOD_SSL_CONF_SRC, options_ssl)
+ _install_current_file()
+ return
+ # there's already a file there. if it exactly matches a previous file hash,
+ # we can update it. otherwise, print a warning once per new version.
+ active_file_digest = crypto_util.sha256sum(options_ssl)
+ if active_file_digest in constants.PREVIOUS_SSL_OPTIONS_HASHES: # safe to update
+ _install_current_file()
+ elif active_file_digest == constants.CURRENT_SSL_OPTIONS_HASH: # already up to date
+ return
+ else: # has been manually modified, not safe to update
+ # did they modify the current version or an old version?
+ if os.path.isfile(options_ssl_digest):
+ with open(options_ssl_digest, "r") as f:
+ saved_digest = f.read()
+ # they modified it after we either installed or told them about this version, so return
+ if saved_digest == constants.CURRENT_SSL_OPTIONS_HASH:
+ return
+ # there's a new version but we couldn't update the file, or they deleted the digest.
+ # save the current digest so we only print this once, and print a warning
+ _write_current_hash()
+ logger.warning("%s has been manually modified; updated ssl configuration options "
+ "saved to %s. We recommend updating %s for security purposes.",
+ options_ssl, constants.MOD_SSL_CONF_SRC, options_ssl)
diff --git a/certbot-nginx/certbot_nginx/constants.py b/certbot-nginx/certbot_nginx/constants.py
index 8cf1f6bc9..765bdd7a8 100644
--- a/certbot-nginx/certbot_nginx/constants.py
+++ b/certbot-nginx/certbot_nginx/constants.py
@@ -17,6 +17,21 @@ MOD_SSL_CONF_SRC = pkg_resources.resource_filename(
"""Path to the nginx mod_ssl config file found in the Certbot
distribution."""
+UPDATED_MOD_SSL_CONF_DIGEST = ".updated-options-ssl-nginx-conf-digest.txt"
+"""Name of the hash of the updated or informed mod_ssl_conf as saved in `IConfig.config_dir`."""
+
+
+PREVIOUS_SSL_OPTIONS_HASHES = [
+ '0f81093a1465e3d4eaa8b0c14e77b2a2e93568b0fc1351c2b87893a95f0de87c',
+ '9a7b32c49001fed4cff8ad24353329472a50e86ade1ef9b2b9e43566a619612e',
+ 'a6d9f1c7d6b36749b52ba061fff1421f9a0a3d2cfdafbd63c05d06f65b990937',
+ '7f95624dd95cf5afc708b9f967ee83a24b8025dc7c8d9df2b556bbc64256b3ff',
+]
+"""SHA256 hashes of the contents of previous versions of MOD_SSL_CONF_SRC"""
+
+CURRENT_SSL_OPTIONS_HASH = '394732f2bbe3e5e637c3fb5c6e980a1f1b90b01e2e8d6b7cff41dde16e2a756d'
+"""SHA256 hash of the current contents of MOD_SSL_CONF_SRC"""
+
def os_constant(key):
# XXX TODO: In the future, this could return different constants
# based on what OS we are running under. To see an
diff --git a/certbot-nginx/certbot_nginx/options-ssl-nginx.conf b/certbot-nginx/certbot_nginx/options-ssl-nginx.conf
index e1839909d..7303f9bc6 100644
--- a/certbot-nginx/certbot_nginx/options-ssl-nginx.conf
+++ b/certbot-nginx/certbot_nginx/options-ssl-nginx.conf
@@ -1,3 +1,8 @@
+# This file contains important security parameters. If you modify this file manually,
+# Certbot will be unable to automatically provide future security updates.
+# Instead, you will need to manually update this file by referencing the contents of
+# options-ssl-nginx.conf.new.
+
ssl_session_cache shared:le_nginx_SSL:1m;
ssl_session_timeout 1440m;
diff --git a/certbot-nginx/certbot_nginx/parser.py b/certbot-nginx/certbot_nginx/parser.py
index 9f1a08b3b..4e4aa36ca 100644
--- a/certbot-nginx/certbot_nginx/parser.py
+++ b/certbot-nginx/certbot_nginx/parser.py
@@ -24,10 +24,10 @@ class NginxParser(object):
"""
- def __init__(self, root, ssl_options):
+ def __init__(self, root):
self.parsed = {}
self.root = os.path.abspath(root)
- self.loc = self._set_locations(ssl_options)
+ self.config_root = self._find_config_root()
# Parse nginx.conf and included files.
# TODO: Check sites-available/ as well. For now, the configurator does
@@ -39,7 +39,7 @@ class NginxParser(object):
"""
self.parsed = {}
- self._parse_recursively(self.loc["root"])
+ self._parse_recursively(self.config_root)
def _parse_recursively(self, filepath):
"""Parses nginx config files recursively by looking at 'include'
@@ -209,40 +209,8 @@ class NginxParser(object):
logger.debug("Could not parse file: %s due to %s", item, err)
return trees
- def _parse_ssl_options(self, ssl_options):
- if ssl_options is not None:
- try:
- with open(ssl_options) as _file:
- return nginxparser.load(_file).spaced
- except IOError:
- logger.warn("Missing NGINX TLS options file: %s", ssl_options)
- except pyparsing.ParseBaseException as err:
- logger.debug("Could not parse file: %s due to %s", ssl_options, err)
- return []
-
- def _set_locations(self, ssl_options):
- """Set default location for directives.
-
- Locations are given as file_paths
- .. todo:: Make sure that files are included
-
- """
- root = self._find_config_root()
- default = root
-
- nginx_temp = os.path.join(self.root, "nginx_ports.conf")
- if os.path.isfile(nginx_temp):
- listen = nginx_temp
- name = nginx_temp
- else:
- listen = default
- name = default
-
- return {"root": root, "default": default, "listen": listen,
- "name": name, "ssl_options": self._parse_ssl_options(ssl_options)}
-
def _find_config_root(self):
- """Find the Nginx Configuration Root file."""
+ """Return the Nginx Configuration Root file."""
location = ['nginx.conf']
for name in location:
@@ -344,6 +312,16 @@ class NginxParser(object):
except errors.MisconfigurationError as err:
raise errors.MisconfigurationError("Problem in %s: %s" % (filename, str(err)))
+def _parse_ssl_options(ssl_options):
+ if ssl_options is not None:
+ try:
+ with open(ssl_options) as _file:
+ return nginxparser.load(_file)
+ except IOError:
+ logger.warn("Missing NGINX TLS options file: %s", ssl_options)
+ except pyparsing.ParseBaseException as err:
+ logger.debug("Could not parse file: %s due to %s", ssl_options, err)
+ return []
def _do_for_subarray(entry, condition, func, path=None):
"""Executes a function for a subarray of a nested array if it matches
@@ -501,11 +479,11 @@ def _add_directives(block, directives, replace):
block.append(nginxparser.UnspacedList('\n'))
-REPEATABLE_DIRECTIVES = set(['server_name', 'listen', 'include'])
+INCLUDE = 'include'
+REPEATABLE_DIRECTIVES = set(['server_name', 'listen', INCLUDE])
COMMENT = ' managed by Certbot'
COMMENT_BLOCK = [' ', '#', COMMENT]
-
def _comment_directive(block, location):
"""Add a comment to the end of the line at location."""
next_entry = block[location + 1] if location + 1 < len(block) else None
@@ -521,6 +499,28 @@ def _comment_directive(block, location):
if next_entry is not None and "\n" not in next_entry:
block.insert(location + 2, '\n')
+def _comment_out_directive(block, location, include_location):
+ """Comment out the line at location, with a note of explanation."""
+ comment_message = ' duplicated in {0}'.format(include_location)
+ # add the end comment
+ # create a dumpable object out of block[location] (so it includes the ;)
+ directive = block[location]
+ new_dir_block = nginxparser.UnspacedList([]) # just a wrapper
+ new_dir_block.append(directive)
+ dumped = nginxparser.dumps(new_dir_block)
+ commented = dumped + ' #' + comment_message # add the comment directly to the one-line string
+ new_dir = nginxparser.loads(commented) # reload into UnspacedList
+
+ # add the beginning comment
+ insert_location = 0
+ if new_dir[0].spaced[0] != new_dir[0][0]: # if there's whitespace at the beginning
+ insert_location = 1
+ new_dir[0].spaced.insert(insert_location, "# ") # comment out the line
+ new_dir[0].spaced.append(";") # directly add in the ;, because now dumping won't work properly
+ dumped = nginxparser.dumps(new_dir)
+ new_dir = nginxparser.loads(dumped) # reload into an UnspacedList
+
+ block[location] = new_dir[0] # set the now-single-line-comment directive back in place
def _add_directive(block, directive, replace):
"""Adds or replaces a single directive in a config block.
@@ -529,15 +529,23 @@ def _add_directive(block, directive, replace):
"""
directive = nginxparser.UnspacedList(directive)
- if len(directive) == 0 or directive[0] == '#':
+ def is_whitespace_or_comment(directive):
+ """Is this directive either a whitespace or comment directive?"""
+ return len(directive) == 0 or directive[0] == '#'
+ if is_whitespace_or_comment(directive):
# whitespace or comment
block.append(directive)
return
- # Find the index of a config line where the name of the directive matches
- # the name of the directive we want to add. If no line exists, use None.
- location = next((index for index, line in enumerate(block)
- if line and line[0] == directive[0]), None)
+ def find_location(direc):
+ """ Find the index of a config line where the name of the directive matches
+ the name of the directive we want to add. If no line exists, use None.
+ """
+ return next((index for index, line in enumerate(block) \
+ if line and line[0] == direc[0]), None)
+
+ location = find_location(directive)
+
if replace:
if location is None:
raise errors.MisconfigurationError(
@@ -549,15 +557,39 @@ def _add_directive(block, directive, replace):
# Append directive. Fail if the name is not a repeatable directive name,
# and there is already a copy of that directive with a different value
# in the config file.
+
+ # handle flat include files
+
directive_name = directive[0]
- if location is None or (isinstance(directive_name, str) and
- directive_name in REPEATABLE_DIRECTIVES):
+ def can_append(loc, dir_name):
+ """ Can we append this directive to the block? """
+ return loc is None or (isinstance(dir_name, str) and dir_name in REPEATABLE_DIRECTIVES)
+
+ err_fmt = 'tried to insert directive "{0}" but found conflicting "{1}".'
+
+ # Give a better error message about the specific directive than Nginx's "fail to restart"
+ if directive_name == INCLUDE:
+ # in theory, we might want to do this recursively, but in practice, that's really not
+ # necessary because we know what file we're talking about (and if we don't recurse, we
+ # just give a worse error message)
+ included_directives = _parse_ssl_options(directive[1])
+
+ for included_directive in included_directives:
+ included_dir_loc = find_location(included_directive)
+ included_dir_name = included_directive[0]
+ if not is_whitespace_or_comment(included_directive) \
+ and not can_append(included_dir_loc, included_dir_name):
+ if block[included_dir_loc] != included_directive:
+ raise errors.MisconfigurationError(err_fmt.format(included_directive,
+ block[included_dir_loc]))
+ else:
+ _comment_out_directive(block, included_dir_loc, directive[1])
+
+ if can_append(location, directive_name):
block.append(directive)
_comment_directive(block, len(block) - 1)
elif block[location] != directive:
- raise errors.MisconfigurationError(
- 'tried to insert directive "{0}" but found '
- 'conflicting "{1}".'.format(directive, block[location]))
+ raise errors.MisconfigurationError(err_fmt.format(directive, block[location]))
def _apply_global_addr_ssl(addr_to_ssl, parsed_server):
"""Apply global sslishness information to the parsed server block
diff --git a/certbot-nginx/certbot_nginx/tests/configurator_test.py b/certbot-nginx/certbot_nginx/tests/configurator_test.py
index b9e70cd59..215fe3165 100644
--- a/certbot-nginx/certbot_nginx/tests/configurator_test.py
+++ b/certbot-nginx/certbot_nginx/tests/configurator_test.py
@@ -11,8 +11,11 @@ from acme import challenges
from acme import messages
from certbot import achallenges
+from certbot import crypto_util
from certbot import errors
+from certbot.tests import util as certbot_test_util
+from certbot_nginx import constants
from certbot_nginx import obj
from certbot_nginx import parser
from certbot_nginx.tests import util
@@ -44,8 +47,6 @@ class NginxConfiguratorTest(util.NginxTest):
def test_prepare(self):
self.assertEqual((1, 6, 2), self.config.version)
self.assertEqual(8, len(self.config.parser.parsed))
- # ensure we successfully parsed a file for ssl_options
- self.assertTrue(self.config.parser.loc["ssl_options"])
@mock.patch("certbot_nginx.configurator.util.exe_exists")
@mock.patch("certbot_nginx.configurator.subprocess.Popen")
@@ -65,6 +66,23 @@ class NginxConfiguratorTest(util.NginxTest):
self.config.prepare()
self.assertEqual((1, 6, 2), self.config.version)
+ def test_prepare_locked(self):
+ server_root = self.config.conf("server-root")
+ self.config.config_test = mock.Mock()
+ os.remove(os.path.join(server_root, ".certbot.lock"))
+ certbot_test_util.lock_and_call(self._test_prepare_locked, server_root)
+
+ @mock.patch("certbot_nginx.configurator.util.exe_exists")
+ def _test_prepare_locked(self, unused_exe_exists):
+ try:
+ self.config.prepare()
+ except errors.PluginError as err:
+ err_msg = str(err)
+ self.assertTrue("lock" in err_msg)
+ self.assertTrue(self.config.conf("server-root") in err_msg)
+ else: # pragma: no cover
+ self.fail("Exception wasn't raised!")
+
@mock.patch("certbot_nginx.configurator.socket.gethostbyaddr")
def test_get_all_names(self, mock_gethostbyaddr):
mock_gethostbyaddr.return_value = ('155.225.50.69.nephoscale.net', [], [])
@@ -207,8 +225,8 @@ class NginxConfiguratorTest(util.NginxTest):
['listen', '5001', 'ssl'],
['ssl_certificate', 'example/fullchain.pem'],
- ['ssl_certificate_key', 'example/key.pem']] +
- util.filter_comments(self.config.parser.loc["ssl_options"])
+ ['ssl_certificate_key', 'example/key.pem'],
+ ['include', self.config.mod_ssl_conf]]
]],
parsed_example_conf)
self.assertEqual([['server_name', 'somename', 'alias', 'another.alias']],
@@ -225,8 +243,8 @@ class NginxConfiguratorTest(util.NginxTest):
['index', 'index.html', 'index.htm']]],
['listen', '5001', 'ssl'],
['ssl_certificate', '/etc/nginx/fullchain.pem'],
- ['ssl_certificate_key', '/etc/nginx/key.pem']] +
- util.filter_comments(self.config.parser.loc["ssl_options"])
+ ['ssl_certificate_key', '/etc/nginx/key.pem'],
+ ['include', self.config.mod_ssl_conf]]
],
2))
@@ -249,8 +267,8 @@ class NginxConfiguratorTest(util.NginxTest):
['listen', '80'],
['listen', '5001', 'ssl'],
['ssl_certificate', 'summer/fullchain.pem'],
- ['ssl_certificate_key', 'summer/key.pem']] +
- util.filter_comments(self.config.parser.loc["ssl_options"])
+ ['ssl_certificate_key', 'summer/key.pem'],
+ ['include', self.config.mod_ssl_conf]]
],
parsed_migration_conf[0])
@@ -521,6 +539,79 @@ class NginxConfiguratorTest(util.NginxTest):
self.assertTrue(util.contains_at_depth(
generated_conf, ['ssl_stapling_verify', 'on'], 2))
+class InstallSslOptionsConfTest(util.NginxTest):
+ """Test that the options-ssl-nginx.conf file is installed and updated properly."""
+
+ def setUp(self):
+ super(InstallSslOptionsConfTest, self).setUp()
+
+ self.config = util.get_nginx_configurator(
+ self.config_path, self.config_dir, self.work_dir, self.logs_dir)
+
+ def _call(self):
+ from certbot_nginx.configurator import install_ssl_options_conf
+ install_ssl_options_conf(self.config.mod_ssl_conf, self.config.updated_mod_ssl_conf_digest)
+
+ def _assert_current_file(self):
+ """If this is failing, remember that constants.PREVIOUS_SSL_OPTIONS_HASHES and
+ constants.CURRENT_SSL_OPTIONS_HASH must be updated when self.config.mod_ssl_conf
+ is updated. Add CURRENT_SSL_OPTIONS_HASH to PREVIOUS_SSL_OPTIONS_HASHES and set
+ CURRENT_SSL_OPTIONS_HASH to the hash of the updated self.config.mod_ssl_conf."""
+ self.assertTrue(os.path.isfile(self.config.mod_ssl_conf))
+ from certbot_nginx.constants import CURRENT_SSL_OPTIONS_HASH
+ self.assertEqual(crypto_util.sha256sum(self.config.mod_ssl_conf), CURRENT_SSL_OPTIONS_HASH)
+
+ def test_no_file(self):
+ # prepare should have placed a file there
+ self._assert_current_file()
+ os.remove(self.config.mod_ssl_conf)
+ self.assertFalse(os.path.isfile(self.config.mod_ssl_conf))
+ self._call()
+ self._assert_current_file()
+
+ def test_current_file(self):
+ self._assert_current_file()
+ self._call()
+ self._assert_current_file()
+
+ def test_prev_file_updates_to_current(self):
+ from certbot_nginx.constants import PREVIOUS_SSL_OPTIONS_HASHES
+ with mock.patch('certbot.crypto_util.sha256sum') as mock_sha256:
+ mock_sha256.return_value = PREVIOUS_SSL_OPTIONS_HASHES[0]
+ self._call()
+ self._assert_current_file()
+
+ def test_manually_modified_current_file_does_not_update(self):
+ with open(self.config.mod_ssl_conf, "a") as mod_ssl_conf:
+ mod_ssl_conf.write("a new line for the wrong hash\n")
+ with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+ self._call()
+ self.assertFalse(mock_logger.warning.called)
+ self.assertTrue(os.path.isfile(self.config.mod_ssl_conf))
+ from certbot_nginx.constants import CURRENT_SSL_OPTIONS_HASH
+ self.assertEqual(crypto_util.sha256sum(constants.MOD_SSL_CONF_SRC),
+ CURRENT_SSL_OPTIONS_HASH)
+ self.assertNotEqual(crypto_util.sha256sum(self.config.mod_ssl_conf),
+ CURRENT_SSL_OPTIONS_HASH)
+
+ def test_manually_modified_past_file_warns(self):
+ with open(self.config.mod_ssl_conf, "a") as mod_ssl_conf:
+ mod_ssl_conf.write("a new line for the wrong hash\n")
+ with open(self.config.updated_mod_ssl_conf_digest, "w") as f:
+ f.write("hashofanoldversion")
+ with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+ self._call()
+ self.assertEqual(mock_logger.warning.call_args[0][0],
+ "%s has been manually modified; updated ssl configuration options "
+ "saved to %s. We recommend updating %s for security purposes.")
+ from certbot_nginx.constants import CURRENT_SSL_OPTIONS_HASH
+ self.assertEqual(crypto_util.sha256sum(constants.MOD_SSL_CONF_SRC),
+ CURRENT_SSL_OPTIONS_HASH)
+ # only print warning once
+ with mock.patch("certbot_nginx.configurator.logger") as mock_logger:
+ self._call()
+ self.assertFalse(mock_logger.warning.called)
+
if __name__ == "__main__":
unittest.main() # pragma: no cover
diff --git a/certbot-nginx/certbot_nginx/tests/parser_test.py b/certbot-nginx/certbot_nginx/tests/parser_test.py
index 8a8bd0ff1..3877bf5d4 100644
--- a/certbot-nginx/certbot_nginx/tests/parser_test.py
+++ b/certbot-nginx/certbot_nginx/tests/parser_test.py
@@ -13,7 +13,7 @@ from certbot_nginx import parser
from certbot_nginx.tests import util
-class NginxParserTest(util.NginxTest):
+class NginxParserTest(util.NginxTest): #pylint: disable=too-many-public-methods
"""Nginx Parser Test."""
def setUp(self):
@@ -27,22 +27,22 @@ class NginxParserTest(util.NginxTest):
def test_root_normalized(self):
path = os.path.join(self.temp_dir, "etc_nginx/////"
"ubuntu_nginx/../../etc_nginx")
- nparser = parser.NginxParser(path, None)
+ nparser = parser.NginxParser(path)
self.assertEqual(nparser.root, self.config_path)
def test_root_absolute(self):
- nparser = parser.NginxParser(os.path.relpath(self.config_path), None)
+ nparser = parser.NginxParser(os.path.relpath(self.config_path))
self.assertEqual(nparser.root, self.config_path)
def test_root_no_trailing_slash(self):
- nparser = parser.NginxParser(self.config_path + os.path.sep, None)
+ nparser = parser.NginxParser(self.config_path + os.path.sep)
self.assertEqual(nparser.root, self.config_path)
def test_load(self):
"""Test recursive conf file parsing.
"""
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
nparser.load()
self.assertEqual(set([nparser.abs_path(x) for x in
['foo.conf', 'nginx.conf', 'server.conf',
@@ -62,13 +62,13 @@ class NginxParserTest(util.NginxTest):
'sites-enabled/example.com')])
def test_abs_path(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
self.assertEqual('/etc/nginx/*', nparser.abs_path('/etc/nginx/*'))
self.assertEqual(os.path.join(self.config_path, 'foo/bar/'),
nparser.abs_path('foo/bar/'))
def test_filedump(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
nparser.filedump('test', lazy=False)
# pylint: disable=protected-access
parsed = nparser._parse_files(nparser.abs_path(
@@ -106,7 +106,7 @@ class NginxParserTest(util.NginxTest):
self.assertEqual(paths, result)
def test_get_vhosts_global_ssl(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
vhosts = nparser.get_vhosts()
vhost = obj.VirtualHost(nparser.abs_path('sites-enabled/globalssl.com'),
@@ -117,7 +117,7 @@ class NginxParserTest(util.NginxTest):
self.assertEqual(vhost, globalssl_com)
def test_get_vhosts(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
vhosts = nparser.get_vhosts()
vhost1 = obj.VirtualHost(nparser.abs_path('nginx.conf'),
@@ -161,7 +161,7 @@ class NginxParserTest(util.NginxTest):
self.assertEqual(vhost2, somename)
def test_has_ssl_on_directive(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
mock_vhost = obj.VirtualHost(None, None, None, None, None,
[['listen', 'myhost default_server'],
['server_name', 'www.example.org'],
@@ -181,7 +181,7 @@ class NginxParserTest(util.NginxTest):
self.assertTrue(nparser.has_ssl_on_directive(mock_vhost))
def test_add_server_directives(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
mock_vhost = obj.VirtualHost(nparser.abs_path('nginx.conf'),
None, None, None,
set(['localhost',
@@ -230,8 +230,35 @@ class NginxParserTest(util.NginxTest):
['ssl_certificate', '/etc/ssl/cert2.pem']],
replace=False)
+ def test_comment_is_repeatable(self):
+ nparser = parser.NginxParser(self.config_path)
+ example_com = nparser.abs_path('sites-enabled/example.com')
+ mock_vhost = obj.VirtualHost(example_com,
+ None, None, None,
+ set(['.example.com', 'example.*']),
+ None, [0])
+ nparser.add_server_directives(mock_vhost,
+ [['\n ', '#', ' ', 'what a nice comment']],
+ replace=False)
+ nparser.add_server_directives(mock_vhost,
+ [['\n ', 'include', ' ',
+ nparser.abs_path('comment_in_file.conf')]],
+ replace=False)
+ from certbot_nginx.parser import COMMENT
+ self.assertEqual(nparser.parsed[example_com],
+ [[['server'], [['listen', '69.50.225.155:9000'],
+ ['listen', '127.0.0.1'],
+ ['server_name', '.example.com'],
+ ['server_name', 'example.*'],
+ ['#', ' ', 'what a nice comment'],
+ [],
+ ['include', nparser.abs_path('comment_in_file.conf')],
+ ['#', COMMENT],
+ []]]]
+)
+
def test_replace_server_directives(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
target = set(['.example.com', 'example.*'])
filep = nparser.abs_path('sites-enabled/example.com')
mock_vhost = obj.VirtualHost(filep, None, None, None, target, None, [0])
@@ -302,6 +329,33 @@ class NginxParserTest(util.NginxTest):
COMMENT_BLOCK,
["\n", "e", " ", "f"]])
+ def test_comment_out_directive(self):
+ server_block = nginxparser.loads("""
+ server {
+ listen 80;
+ root /var/www/html;
+ index star.html;
+
+ server_name *.functorkitten.xyz;
+ ssl_session_timeout 1440m; ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
+
+ ssl_prefer_server_ciphers on;
+ }""")
+ block = server_block[0][1]
+ from certbot_nginx.parser import _comment_out_directive
+ _comment_out_directive(block, 4, "blah1")
+ _comment_out_directive(block, 5, "blah2")
+ _comment_out_directive(block, 6, "blah3")
+ self.assertEqual(block.spaced, [
+ ['\n ', 'listen', ' ', '80'],
+ ['\n ', 'root', ' ', '/var/www/html'],
+ ['\n ', 'index', ' ', 'star.html'],
+ ['\n\n ', 'server_name', ' ', '*.functorkitten.xyz'],
+ ['\n ', '#', ' ssl_session_timeout 1440m; # duplicated in blah1'],
+ [' ', '#', ' ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # duplicated in blah2'],
+ ['\n\n ', '#', ' ssl_prefer_server_ciphers on; # duplicated in blah3'],
+ '\n '])
+
def test_parse_server_raw_ssl(self):
server = parser._parse_server_raw([ #pylint: disable=protected-access
['listen', '443']
@@ -330,33 +384,12 @@ class NginxParserTest(util.NginxTest):
self.assertEqual(len(server['addrs']), 0)
def test_parse_server_global_ssl_applied(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
+ nparser = parser.NginxParser(self.config_path)
server = nparser.parse_server([
['listen', '443']
])
self.assertTrue(server['ssl'])
- def test_ssl_options_should_be_parsed_ssl_directives(self):
- nparser = parser.NginxParser(self.config_path, self.ssl_options)
- self.assertEqual(nginxparser.UnspacedList(nparser.loc["ssl_options"]),
- [['ssl_session_cache', 'shared:le_nginx_SSL:1m'],
- ['ssl_session_timeout', '1440m'],
- ['ssl_protocols', 'TLSv1', 'TLSv1.1', 'TLSv1.2'],
- ['ssl_prefer_server_ciphers', 'on'],
- ['ssl_ciphers', '"ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-'+
- 'RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:'+
- 'ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-'+
- 'SHA384:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES128-GCM-'+
- 'SHA256:DHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-'+
- 'SHA256:ECDHE-RSA-AES128-SHA256:ECDHE-ECDSA-AES128-SHA:'+
- 'ECDHE-RSA-AES256-SHA384:ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-'+
- 'AES256-SHA384:ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:'+
- 'DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-'+
- 'SHA256:DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:ECDHE-'+
- 'RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:'+
- 'AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:'+
- 'AES256-SHA:DES-CBC3-SHA:!DSS"']
- ])
if __name__ == "__main__":
unittest.main() # pragma: no cover
diff --git a/certbot-nginx/certbot_nginx/tests/testdata/etc_nginx/comment_in_file.conf b/certbot-nginx/certbot_nginx/tests/testdata/etc_nginx/comment_in_file.conf
new file mode 100644
index 000000000..f761079fa
--- /dev/null
+++ b/certbot-nginx/certbot_nginx/tests/testdata/etc_nginx/comment_in_file.conf
@@ -0,0 +1 @@
+# a comment inside a file
\ No newline at end of file
diff --git a/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py b/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py
index 7a2de44a2..85db584b3 100644
--- a/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py
+++ b/certbot-nginx/certbot_nginx/tests/tls_sni_01_test.py
@@ -89,7 +89,7 @@ class TlsSniPerformTest(util.NginxTest):
# Make sure challenge config is included in main config
http = self.sni.configurator.parser.parsed[
- self.sni.configurator.parser.loc["root"]][-1]
+ self.sni.configurator.parser.config_root][-1]
self.assertTrue(
util.contains_at_depth(http, ['include', self.sni.challenge_conf], 1))
@@ -112,7 +112,7 @@ class TlsSniPerformTest(util.NginxTest):
mock_setup_cert.call_args_list[index], mock.call(achall))
http = self.sni.configurator.parser.parsed[
- self.sni.configurator.parser.loc["root"]][-1]
+ self.sni.configurator.parser.config_root][-1]
self.assertTrue(['include', self.sni.challenge_conf] in http[1])
self.assertFalse(
util.contains_at_depth(http, ['server_name', 'another.alias'], 3))
@@ -137,7 +137,7 @@ class TlsSniPerformTest(util.NginxTest):
self.sni.configurator.parser.load()
http = self.sni.configurator.parser.parsed[
- self.sni.configurator.parser.loc["root"]][-1]
+ self.sni.configurator.parser.config_root][-1]
self.assertTrue(['include', self.sni.challenge_conf] in http[1])
vhosts = self.sni.configurator.parser.get_vhosts()
@@ -154,7 +154,7 @@ class TlsSniPerformTest(util.NginxTest):
self.assertEqual(len(vhs), 2)
def test_mod_config_fail(self):
- root = self.sni.configurator.parser.loc["root"]
+ root = self.sni.configurator.parser.config_root
self.sni.configurator.parser.parsed[root] = [['include', 'foo.conf']]
# pylint: disable=protected-access
self.assertRaises(
diff --git a/certbot-nginx/certbot_nginx/tests/util.py b/certbot-nginx/certbot_nginx/tests/util.py
index 4ab95374e..2ee38ec38 100644
--- a/certbot-nginx/certbot_nginx/tests/util.py
+++ b/certbot-nginx/certbot_nginx/tests/util.py
@@ -16,7 +16,6 @@ from certbot.tests import util as test_util
from certbot.plugins import common
-from certbot_nginx import constants
from certbot_nginx import configurator
from certbot_nginx import nginxparser
@@ -30,10 +29,6 @@ class NginxTest(unittest.TestCase): # pylint: disable=too-few-public-methods
"etc_nginx", "certbot_nginx.tests")
self.logs_dir = tempfile.mkdtemp('logs')
- self.ssl_options = common.setup_ssl_options(
- self.config_dir, constants.MOD_SSL_CONF_SRC,
- constants.MOD_SSL_CONF_DEST)
-
self.config_path = os.path.join(self.temp_dir, "etc_nginx")
self.rsa512jwk = jose.JWKRSA.load(test_util.load_vector(
diff --git a/certbot-nginx/certbot_nginx/tls_sni_01.py b/certbot-nginx/certbot_nginx/tls_sni_01.py
index 347d9f21f..48e117bba 100644
--- a/certbot-nginx/certbot_nginx/tls_sni_01.py
+++ b/certbot-nginx/certbot_nginx/tls_sni_01.py
@@ -91,7 +91,7 @@ class NginxTlsSni01(common.TLSSNI01):
# already in the main config
included = False
include_directive = ['\n', 'include', ' ', self.challenge_conf]
- root = self.configurator.parser.loc["root"]
+ root = self.configurator.parser.config_root
bucket_directive = ['\n', 'server_names_hash_bucket_size', ' ', '128']
@@ -157,6 +157,6 @@ class NginxTlsSni01(common.TLSSNI01):
self.configurator.config.work_dir, 'error.log')],
['ssl_certificate', ' ', self.get_cert_path(achall)],
['ssl_certificate_key', ' ', self.get_key_path(achall)],
- [['location', ' ', '/'], [['root', ' ', document_root]]]] +
- self.configurator.parser.loc["ssl_options"])
+ ['include', ' ', self.configurator.mod_ssl_conf],
+ [['location', ' ', '/'], [['root', ' ', document_root]]]])
return [['server'], block]
diff --git a/certbot-nginx/setup.cfg b/certbot-nginx/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/certbot-nginx/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/certbot-nginx/setup.py b/certbot-nginx/setup.py
index 786b5a1a1..4329eb858 100644
--- a/certbot-nginx/setup.py
+++ b/certbot-nginx/setup.py
@@ -4,7 +4,7 @@ from setuptools import setup
from setuptools import find_packages
-version = '0.14.0.dev0'
+version = '0.15.0.dev0'
# Please update tox.ini when modifying dependency version requirements
install_requires = [
@@ -42,6 +42,11 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Security',
'Topic :: System :: Installation/Setup',
diff --git a/certbot-route53/.gitignore b/certbot-route53/.gitignore
new file mode 100644
index 000000000..1dbc687de
--- /dev/null
+++ b/certbot-route53/.gitignore
@@ -0,0 +1,62 @@
+# Byte-compiled / optimized / DLL files
+__pycache__/
+*.py[cod]
+*$py.class
+
+# C extensions
+*.so
+
+# Distribution / packaging
+.Python
+env/
+build/
+develop-eggs/
+dist/
+downloads/
+eggs/
+.eggs/
+lib/
+lib64/
+parts/
+sdist/
+var/
+*.egg-info/
+.installed.cfg
+*.egg
+
+# PyInstaller
+# Usually these files are written by a python script from a template
+# before PyInstaller builds the exe, so as to inject date/other infos into it.
+*.manifest
+*.spec
+
+# Installer logs
+pip-log.txt
+pip-delete-this-directory.txt
+
+# Unit test / coverage reports
+htmlcov/
+.tox/
+.coverage
+.coverage.*
+.cache
+nosetests.xml
+coverage.xml
+*,cover
+.hypothesis/
+
+# Translations
+*.mo
+*.pot
+
+# Django stuff:
+*.log
+
+# Sphinx documentation
+docs/_build/
+
+# PyBuilder
+target/
+
+#Ipython Notebook
+.ipynb_checkpoints
diff --git a/certbot-route53/LICENSE b/certbot-route53/LICENSE
new file mode 100644
index 000000000..8dada3eda
--- /dev/null
+++ b/certbot-route53/LICENSE
@@ -0,0 +1,201 @@
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "{}"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright {yyyy} {name of copyright owner}
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
diff --git a/certbot-route53/MANIFEST.in b/certbot-route53/MANIFEST.in
new file mode 100644
index 000000000..9575a1c62
--- /dev/null
+++ b/certbot-route53/MANIFEST.in
@@ -0,0 +1,2 @@
+include LICENSE
+include README
diff --git a/certbot-route53/README.md b/certbot-route53/README.md
new file mode 100644
index 000000000..cec9c295c
--- /dev/null
+++ b/certbot-route53/README.md
@@ -0,0 +1,35 @@
+## Route53 plugin for Let's Encrypt client
+
+### Before you start
+
+It's expected that the root hosted zone for the domain in question already
+exists in your account.
+
+### Setup
+
+1. Create a virtual environment
+
+2. Update its pip and setuptools (`VENV/bin/pip install -U setuptools pip`)
+to avoid problems with cryptography's dependency on setuptools>=11.3.
+
+3. Make sure you have libssl-dev and libffi (or your regional equivalents)
+installed. You might have to set compiler flags to pick things up (I have to
+use `CPPFLAGS=-I/usr/local/opt/openssl/include
+LDFLAGS=-L/usr/local/opt/openssl/lib` on my macOS to pick up brew's openssl,
+for example).
+
+4. Install this package.
+
+### How to use it
+
+Make sure you have access to AWS's Route53 service, either through IAM roles or
+via `.aws/credentials`. Check out
+[sample-aws-policy.json](sample-aws-policy.json) for the necessary permissions.
+
+To generate a certificate:
+```
+certbot certonly \
+ -n --agree-tos --email DEVOPS@COMPANY.COM \
+ -a certbot-route53:auth \
+ -d MY.DOMAIN.NAME
+```
diff --git a/certbot-route53/certbot_route53/__init__.py b/certbot-route53/certbot_route53/__init__.py
new file mode 100644
index 000000000..c91c79c22
--- /dev/null
+++ b/certbot-route53/certbot_route53/__init__.py
@@ -0,0 +1 @@
+"""Certbot Route53 plugin."""
diff --git a/certbot-route53/certbot_route53/authenticator.py b/certbot-route53/certbot_route53/authenticator.py
new file mode 100644
index 000000000..1f1f78bfc
--- /dev/null
+++ b/certbot-route53/certbot_route53/authenticator.py
@@ -0,0 +1,147 @@
+"""Certbot Route53 authenticator plugin."""
+import logging
+import time
+import datetime
+
+import zope.interface
+
+import boto3
+from botocore.exceptions import NoCredentialsError, ClientError
+
+from acme import challenges
+
+from certbot import interfaces
+from certbot.plugins import common
+
+
+logger = logging.getLogger(__name__)
+
+TTL = 10
+
+INSTRUCTIONS = (
+ "To use certbot-route53, configure credentials as described at "
+ "https://boto3.readthedocs.io/en/latest/guide/configuration.html#best-practices-for-configuring-credentials "
+ "and add the necessary permissions for Route53 access.")
+
+@zope.interface.implementer(interfaces.IAuthenticator)
+@zope.interface.provider(interfaces.IPluginFactory)
+class Authenticator(common.Plugin):
+ """Route53 Authenticator
+
+ This authenticator solves a DNS01 challenge by uploading the answer to AWS
+ Route53.
+ """
+
+ description = ("Authenticate domain names using the DNS challenge type, "
+ "by automatically updating TXT records using AWS Route53. Works only "
+ "if you use AWS Route53 to host DNS for your domains. " +
+ INSTRUCTIONS)
+
+ def __init__(self, *args, **kwargs):
+ super(Authenticator, self).__init__(*args, **kwargs)
+ self.r53 = boto3.client("route53")
+
+ def prepare(self): # pylint: disable=missing-docstring,no-self-use
+ pass # pragma: no cover
+
+ def more_info(self): # pylint: disable=missing-docstring,no-self-use
+ return "Solve a DNS01 challenge using AWS Route53"
+
+ def get_chall_pref(self, domain):
+ # pylint: disable=missing-docstring,no-self-use,unused-argument
+ return [challenges.DNS01]
+
+ def perform(self, achalls): # pylint: disable=missing-docstring
+ try:
+ change_ids = [
+ self._change_txt_record("UPSERT", achall)
+ for achall in achalls
+ ]
+
+ for change_id in change_ids:
+ self._wait_for_change(change_id)
+ # Sleep for at least the TTL, to ensure that any records cached by
+ # the ACME server after previous validation attempts are gone. In
+ # most cases we'll need to wait at least this long for the Route53
+ # records to propagate, so this doesn't delay us much.
+ time.sleep(TTL)
+ return [achall.response(achall.account_key) for achall in achalls]
+ except (NoCredentialsError, ClientError) as e:
+ e.args = ("\n".join([str(e), INSTRUCTIONS]),)
+ raise
+
+ def cleanup(self, achalls): # pylint: disable=missing-docstring
+ for achall in achalls:
+ self._change_txt_record("DELETE", achall)
+
+ def _find_zone_id_for_domain(self, domain):
+ """Find the zone id responsible a given FQDN.
+
+ That is, the id for the zone whose name is the longest parent of the
+ domain.
+ """
+ paginator = self.r53.get_paginator("list_hosted_zones")
+ zones = []
+ target_labels = domain.rstrip(".").split(".")
+ for page in paginator.paginate():
+ for zone in page["HostedZones"]:
+ if zone["Config"]["PrivateZone"]:
+ continue
+
+ candidate_labels = zone["Name"].rstrip(".").split(".")
+ if candidate_labels == target_labels[-len(candidate_labels):]:
+ zones.append((zone["Name"], zone["Id"]))
+
+ if not zones:
+ raise ValueError(
+ "Unable to find a Route53 hosted zone for {}".format(domain)
+ )
+
+ # Order the zones that are suffixes for our desired to domain by
+ # length, this puts them in an order like:
+ # ["foo.bar.baz.com", "bar.baz.com", "baz.com", "com"]
+ # And then we choose the first one, which will be the most specific.
+ zones.sort(key=lambda z: len(z[0]), reverse=True)
+ return zones[0][1]
+
+ def _change_txt_record(self, action, achall):
+ domain = achall.validation_domain_name(achall.domain)
+ value = achall.validation(achall.account_key)
+
+ zone_id = self._find_zone_id_for_domain(domain)
+
+ response = self.r53.change_resource_record_sets(
+ HostedZoneId=zone_id,
+ ChangeBatch={
+ "Comment": "certbot-route53 certificate validation " + action,
+ "Changes": [
+ {
+ "Action": action,
+ "ResourceRecordSet": {
+ "Name": domain,
+ "Type": "TXT",
+ "TTL": TTL,
+ "ResourceRecords": [
+ # For some reason TXT records need to be
+ # manually quoted.
+ {"Value": '"{}"'.format(value)}
+ ],
+ }
+ }
+ ]
+ }
+ )
+ return response["ChangeInfo"]["Id"]
+
+ def _wait_for_change(self, change_id):
+ """Wait for a change to be propagated to all Route53 DNS servers.
+ https://docs.aws.amazon.com/Route53/latest/APIReference/API_GetChange.html
+ """
+ for n in range(0, 120):
+ response = self.r53.get_change(Id=change_id)
+ if response["ChangeInfo"]["Status"] == "INSYNC":
+ return
+ time.sleep(5)
+ raise Exception(
+ "Timed out waiting for Route53 change. Current status: %s" %
+ response["ChangeInfo"]["Status"])
diff --git a/certbot-route53/sample-aws-policy.json b/certbot-route53/sample-aws-policy.json
new file mode 100644
index 000000000..0b4dcae41
--- /dev/null
+++ b/certbot-route53/sample-aws-policy.json
@@ -0,0 +1,25 @@
+{
+ "Version": "2012-10-17",
+ "Id": "certbot-route53 sample policy",
+ "Statement": [
+ {
+ "Effect": "Allow",
+ "Action": [
+ "route53:ListHostedZones",
+ "route53:GetChange"
+ ],
+ "Resource": [
+ "*"
+ ]
+ },
+ {
+ "Effect" : "Allow",
+ "Action" : [
+ "route53:ChangeResourceRecordSets"
+ ],
+ "Resource" : [
+ "arn:aws:route53:::hostedzone/YOURHOSTEDZONEID"
+ ]
+ }
+ ]
+}
diff --git a/certbot-route53/setup.cfg b/certbot-route53/setup.cfg
new file mode 100644
index 000000000..3c6e79cf3
--- /dev/null
+++ b/certbot-route53/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal=1
diff --git a/certbot-route53/setup.py b/certbot-route53/setup.py
new file mode 100644
index 000000000..49b1ea467
--- /dev/null
+++ b/certbot-route53/setup.py
@@ -0,0 +1,47 @@
+import sys
+
+from distutils.core import setup
+from setuptools import find_packages
+
+version = '0.1.5'
+
+install_requires = [
+ 'acme>=0.9.0',
+ 'certbot>=0.9.0',
+ 'zope.interface',
+ 'boto3',
+]
+
+setup(
+ name='certbot-route53',
+ version=version,
+ description="Route53 plugin for certbot",
+ url='https://github.com/lifeonmarspt/certbot-route53',
+ author="Hugo Peixoto",
+ author_email='hugo@lifeonmars.pt',
+ license='Apache2.0',
+ classifiers=[
+ 'Development Status :: 3 - Alpha',
+ 'Environment :: Plugins',
+ 'Intended Audience :: System Administrators',
+ 'License :: OSI Approved :: Apache Software License',
+ 'Operating System :: POSIX :: Linux',
+ 'Programming Language :: Python',
+ 'Programming Language :: Python :: 2',
+ 'Programming Language :: Python :: 2.7',
+ 'Topic :: Internet :: WWW/HTTP',
+ 'Topic :: Security',
+ 'Topic :: System :: Installation/Setup',
+ 'Topic :: System :: Networking',
+ 'Topic :: System :: Systems Administration',
+ 'Topic :: Utilities',
+ ],
+ packages=find_packages(),
+ install_requires=install_requires,
+ keywords=['certbot', 'route53', 'aws'],
+ entry_points={
+ 'certbot.plugins': [
+ 'auth = certbot_route53.authenticator:Authenticator'
+ ],
+ },
+)
diff --git a/certbot-route53/tester.pkoch-macos_sierra.sh b/certbot-route53/tester.pkoch-macos_sierra.sh
new file mode 100755
index 000000000..dbbaa2251
--- /dev/null
+++ b/certbot-route53/tester.pkoch-macos_sierra.sh
@@ -0,0 +1,16 @@
+#!/bin/bash
+# I just wanted a place to dump the incantations I use for testing.
+set -e
+
+brew install openssl libffi
+
+rm -rf scratch; mkdir scratch
+
+virtualenv scratch/venv -p /usr/local/bin/python2.7
+scratch/venv/bin/pip install -U pip setuptools
+
+CPPFLAGS=-I/usr/local/opt/openssl/include LDFLAGS=-L/usr/local/opt/openssl/lib scratch/venv/bin/pip install -e .
+
+scratch/venv/bin/certbot certonly -n --agree-tos --test-cert --email pkoch@lifeonmars.pt -a certbot-route53:auth -d pkoch.lifeonmars.pt --work-dir scratch --config-dir scratch --logs-dir scratch
+
+rm -rf scratch
diff --git a/certbot/__init__.py b/certbot/__init__.py
index 228a1a2a6..5128bf096 100644
--- a/certbot/__init__.py
+++ b/certbot/__init__.py
@@ -1,4 +1,4 @@
"""Certbot client."""
# version number like 1.2.3a0, must have at least 2 parts, like 1.2
-__version__ = '0.14.0.dev0'
+__version__ = '0.15.0.dev0'
diff --git a/certbot/cli.py b/certbot/cli.py
index fe1ebcfb5..deb1bb24d 100644
--- a/certbot/cli.py
+++ b/certbot/cli.py
@@ -620,7 +620,9 @@ class HelpfulArgumentParser(object):
% parsed_args.csr[0])
parsed_args.actual_csr = (csr, typ)
- csr_domains, config_domains = set(domains), set(parsed_args.domains)
+
+ csr_domains = set([d.lower() for d in domains])
+ config_domains = set(parsed_args.domains)
if csr_domains != config_domains:
raise errors.ConfigurationError(
"Inconsistent domain requests:\nFrom the CSR: {0}\nFrom command line/config: {1}"
@@ -1109,10 +1111,13 @@ def _create_subparsers(helpful):
helpful.add(
None, "--user-agent", default=None,
help="Set a custom user agent string for the client. User agent strings allow "
- "the CA to collect high level statistics about success rates by OS and "
- "plugin. If you wish to hide your server OS version from the Let's "
+ "the CA to collect high level statistics about success rates by OS, "
+ "plugin and use case, and to know when to deprecate support for past Python "
+ "versions and flags. If you wish to hide this information from the Let's "
'Encrypt server, set this to "". '
- '(default: {0})'.format(sample_user_agent()))
+ '(default: {0}). The flags encoded in the user agent are: '
+ '--duplicate, --force-renew, --allow-subset-of-names, -n, and '
+ 'whether any hooks are set.'.format(sample_user_agent()))
helpful.add("certonly",
"--csr", type=read_file,
help="Path to a Certificate Signing Request (CSR) in DER or PEM format."
@@ -1213,11 +1218,21 @@ def _plugins_parsing(helpful, plugins):
helpful.add(["plugins", "certonly", "run", "install", "config_changes"],
"--nginx", action="store_true", help="Obtain and install certificates using Nginx")
helpful.add(["plugins", "certonly"], "--standalone", action="store_true",
- help='Obtain certs using a "standalone" webserver.')
+ help='Obtain certificates using a "standalone" webserver.')
helpful.add(["plugins", "certonly"], "--manual", action="store_true",
help='Provide laborious manual instructions for obtaining a certificate')
helpful.add(["plugins", "certonly"], "--webroot", action="store_true",
help='Obtain certificates by placing files in a webroot directory.')
+ helpful.add(["plugins", "certonly"], "--dns-cloudflare", action="store_true",
+ help='Obtain certificates using a DNS TXT record (if you are using Cloudflare for DNS).')
+ helpful.add(["plugins", "certonly"], "--dns-cloudxns", action="store_true",
+ help='Obtain certificates using a DNS TXT record (if you are using CloudXNS for DNS).')
+ helpful.add(["plugins", "certonly"], "--dns-digitalocean", action="store_true",
+ help='Obtain certificates using a DNS TXT record (if you are using DigitalOcean for DNS).')
+ helpful.add(["plugins", "certonly"], "--dns-dnsimple", action="store_true",
+ help='Obtain certificates using a DNS TXT record (if you are using DNSimple for DNS).')
+ helpful.add(["plugins", "certonly"], "--dns-google", action="store_true",
+ help='Obtain certificates using a DNS TXT record (if you are using Google Cloud DNS).')
# things should not be reorder past/pre this comment:
# plugins_group should be displayed in --help before plugin
diff --git a/certbot/client.py b/certbot/client.py
index 3e9caad40..13b35e164 100644
--- a/certbot/client.py
+++ b/certbot/client.py
@@ -1,6 +1,7 @@
"""Certbot client API."""
import logging
import os
+import platform
from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives.asymmetric import rsa
@@ -53,21 +54,49 @@ def determine_user_agent(config):
"""
if config.user_agent is None:
- ua = "CertbotACMEClient/{0} ({1}) Authenticator/{2} Installer/{3}"
- ua = ua.format(certbot.__version__, util.get_os_info_ua(),
- config.authenticator, config.installer)
+ ua = ("CertbotACMEClient/{0} ({1}; {2}) Authenticator/{3} Installer/{4} "
+ "({5}; flags: {6}) Py/{7}")
+ ua = ua.format(certbot.__version__, cli.cli_command, util.get_os_info_ua(),
+ config.authenticator, config.installer, config.verb,
+ ua_flags(config), platform.python_version())
else:
ua = config.user_agent
return ua
+def ua_flags(config):
+ "Turn some very important CLI flags into clues in the user agent."
+ if isinstance(config, DummyConfig):
+ return "FLAGS"
+ flags = []
+ if config.duplicate:
+ flags.append("dup")
+ if config.renew_by_default:
+ flags.append("frn")
+ if config.allow_subset_of_names:
+ flags.append("asn")
+ if config.noninteractive_mode:
+ flags.append("n")
+ hook_names = ("pre", "post", "renew", "manual_auth", "manual_cleanup")
+ hooks = [getattr(config, h + "_hook") for h in hook_names]
+ if any(hooks):
+ flags.append("hook")
+ return " ".join(flags)
+
+class DummyConfig(object):
+ "Shim for computing a sample user agent."
+ def __init__(self):
+ self.authenticator = "XXX"
+ self.installer = "YYY"
+ self.user_agent = None
+ self.verb = "SUBCOMMAND"
+
+ def __getattr__(self, name):
+ "Any config properties we might have are None."
+ return None
+
def sample_user_agent():
"Document what this Certbot's user agent string will be like."
- class DummyConfig(object):
- "Shim for computing a sample user agent."
- def __init__(self):
- self.authenticator = "XXX"
- self.installer = "YYY"
- self.user_agent = None
+
return determine_user_agent(DummyConfig())
diff --git a/certbot/crypto_util.py b/certbot/crypto_util.py
index 7de173568..2b2e7d0d8 100644
--- a/certbot/crypto_util.py
+++ b/certbot/crypto_util.py
@@ -4,6 +4,7 @@
is capable of handling the signatures.
"""
+import hashlib
import logging
import os
@@ -353,3 +354,17 @@ def _notAfterBefore(cert_path, method):
if six.PY3:
timestamp_str = timestamp_str.decode('ascii')
return pyrfc3339.parse(timestamp_str)
+
+
+def sha256sum(filename):
+ """Compute a sha256sum of a file.
+
+ :param str filename: path to the file whose hash will be computed
+
+ :returns: sha256 digest of the file in hexadecimal
+ :rtype: str
+ """
+ sha256 = hashlib.sha256()
+ with open(filename, 'rb') as f:
+ sha256.update(f.read())
+ return sha256.hexdigest()
diff --git a/certbot/display/util.py b/certbot/display/util.py
index 4d69f1263..5b01dd8d4 100644
--- a/certbot/display/util.py
+++ b/certbot/display/util.py
@@ -1,10 +1,10 @@
"""Certbot display."""
import logging
import os
-import textwrap
+import select
import sys
+import textwrap
-import six
import zope.interface
from certbot import constants
@@ -51,6 +51,42 @@ def _wrap_lines(msg):
return os.linesep.join(fixed_l)
+
+def input_with_timeout(prompt=None, timeout=36000.0):
+ """Get user input with a timeout.
+
+ Behaves the same as six.moves.input, however, an error is raised if
+ a user doesn't answer after timeout seconds. The default timeout
+ value was chosen to place it just under 12 hours for users following
+ our advice and running Certbot twice a day.
+
+ :param str prompt: prompt to provide for input
+ :param float timeout: maximum number of seconds to wait for input
+
+ :returns: user response
+ :rtype: str
+
+ :raises errors.Error if no answer is given before the timeout
+
+ """
+ # use of sys.stdin and sys.stdout to mimic six.moves.input based on
+ # https://github.com/python/cpython/blob/baf7bb30a02aabde260143136bdf5b3738a1d409/Lib/getpass.py#L129
+ if prompt:
+ sys.stdout.write(prompt)
+ sys.stdout.flush()
+
+ # select can only be used like this on UNIX
+ rlist, _, _ = select.select([sys.stdin], [], [], timeout)
+ if not rlist:
+ raise errors.Error(
+ "Timed out waiting for answer to prompt '{0}'".format(prompt))
+
+ line = rlist[0].readline()
+ if not line:
+ raise EOFError
+ return line.rstrip('\n')
+
+
@zope.interface.implementer(interfaces.IDisplay)
class FileDisplay(object):
"""File-based display."""
@@ -83,7 +119,7 @@ class FileDisplay(object):
line=os.linesep, frame=side_frame, msg=message))
if pause:
if self._can_interact(force_interactive):
- six.moves.input("Press Enter to Continue")
+ input_with_timeout("Press Enter to Continue")
else:
logger.debug("Not pausing for user confirmation")
@@ -140,7 +176,7 @@ class FileDisplay(object):
if self._return_default(message, default, cli_flag, force_interactive):
return OK, default
- ans = six.moves.input(
+ ans = input_with_timeout(
textwrap.fill(
"%s (Enter 'c' to cancel): " % message,
80,
@@ -182,7 +218,7 @@ class FileDisplay(object):
os.linesep, frame=side_frame, msg=message))
while True:
- ans = six.moves.input("{yes}/{no}: ".format(
+ ans = input_with_timeout("{yes}/{no}: ".format(
yes=_parens_around_char(yes_label),
no=_parens_around_char(no_label)))
@@ -388,7 +424,7 @@ class FileDisplay(object):
input_msg = ("Press 1 [enter] to confirm the selection "
"(press 'c' to cancel): ")
while selection < 1:
- ans = six.moves.input(input_msg)
+ ans = input_with_timeout(input_msg)
if ans.startswith("c") or ans.startswith("C"):
return CANCEL, -1
try:
diff --git a/certbot/errors.py b/certbot/errors.py
index 6d191404c..551add61c 100644
--- a/certbot/errors.py
+++ b/certbot/errors.py
@@ -33,6 +33,10 @@ class SignalExit(Error):
"""A Unix signal was received while in the ErrorHandler context manager."""
+class LockError(Error):
+ """File locking error."""
+
+
# Auth Handler Errors
class AuthorizationError(Error):
"""Authorization error."""
diff --git a/certbot/hooks.py b/certbot/hooks.py
index 75d7a3b20..b3c1fc3e2 100644
--- a/certbot/hooks.py
+++ b/certbot/hooks.py
@@ -126,11 +126,13 @@ def execute(shell_cmd):
cmd = Popen(shell_cmd, shell=True, stdout=PIPE,
stderr=PIPE, universal_newlines=True)
out, err = cmd.communicate()
+ base_cmd = os.path.basename(shell_cmd.split(None, 1)[0])
+ if out:
+ logger.info('Output from %s:\n%s', base_cmd, out)
if cmd.returncode != 0:
logger.error('Hook command "%s" returned error code %d',
shell_cmd, cmd.returncode)
if err:
- base_cmd = os.path.basename(shell_cmd.split(None, 1)[0])
logger.error('Error output from %s:\n%s', base_cmd, err)
return (err, out)
diff --git a/certbot/lock.py b/certbot/lock.py
new file mode 100644
index 000000000..5f59cc090
--- /dev/null
+++ b/certbot/lock.py
@@ -0,0 +1,139 @@
+"""Implements file locks for locking files and directories in UNIX."""
+import errno
+import fcntl
+import logging
+import os
+
+from certbot import errors
+
+logger = logging.getLogger(__name__)
+
+
+def lock_dir(dir_path):
+ """Place a lock file on the directory at dir_path.
+
+ The lock file is placed in the root of dir_path with the name
+ .certbot.lock.
+
+ :param str dir_path: path to directory
+
+ :returns: the locked LockFile object
+ :rtype: LockFile
+
+ :raises errors.LockError: if unable to acquire the lock
+
+ """
+ return LockFile(os.path.join(dir_path, '.certbot.lock'))
+
+
+class LockFile(object):
+ """A UNIX lock file.
+
+ This lock file is released when the locked file is closed or the
+ process exits. It cannot be used to provide synchronization between
+ threads. It is based on the lock_file package by Martin Horcicka.
+
+ """
+ def __init__(self, path):
+ """Initialize and acquire the lock file.
+
+ :param str path: path to the file to lock
+
+ :raises errors.LockError: if unable to acquire the lock
+
+ """
+ super(LockFile, self).__init__()
+ self._path = path
+ self._fd = None
+
+ self.acquire()
+
+ def acquire(self):
+ """Acquire the lock file.
+
+ :raises errors.LockError: if lock is already held
+ :raises OSError: if unable to open or stat the lock file
+
+ """
+ while self._fd is None:
+ # Open the file
+ fd = os.open(self._path, os.O_CREAT | os.O_WRONLY, 0o600)
+ try:
+ self._try_lock(fd)
+ if self._lock_success(fd):
+ self._fd = fd
+ finally:
+ # Close the file if it is not the required one
+ if self._fd is None:
+ os.close(fd)
+
+ def _try_lock(self, fd):
+ """Try to acquire the lock file without blocking.
+
+ :param int fd: file descriptor of the opened file to lock
+
+ """
+ try:
+ fcntl.lockf(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
+ except IOError as err:
+ if err.errno in (errno.EACCES, errno.EAGAIN):
+ logger.debug(
+ "A lock on %s is held by another process.", self._path)
+ raise errors.LockError(
+ "Another instance of Certbot is already running.")
+ raise
+
+ def _lock_success(self, fd):
+ """Did we successfully grab the lock?
+
+ Because this class deletes the locked file when the lock is
+ released, it is possible another process removed and recreated
+ the file between us opening the file and acquiring the lock.
+
+ :param int fd: file descriptor of the opened file to lock
+
+ :returns: True if the lock was successfully acquired
+ :rtype: bool
+
+ """
+ try:
+ stat1 = os.stat(self._path)
+ except OSError as err:
+ if err.errno == errno.ENOENT:
+ return False
+ raise
+
+ stat2 = os.fstat(fd)
+ # If our locked file descriptor and the file on disk refer to
+ # the same device and inode, they're the same file.
+ return stat1.st_dev == stat2.st_dev and stat1.st_ino == stat2.st_ino
+
+ def __repr__(self):
+ repr_str = '{0}({1}) <'.format(self.__class__.__name__, self._path)
+ if self._fd is None:
+ repr_str += 'released>'
+ else:
+ repr_str += 'acquired>'
+ return repr_str
+
+ def release(self):
+ """Remove, close, and release the lock file."""
+ # It is important the lock file is removed before it's released,
+ # otherwise:
+ #
+ # process A: open lock file
+ # process B: release lock file
+ # process A: lock file
+ # process A: check device and inode
+ # process B: delete file
+ # process C: open and lock a different file at the same path
+ #
+ # Calling os.remove on a file that's in use doesn't work on
+ # Windows, but neither does locking with fcntl.
+ try:
+ os.remove(self._path)
+ finally:
+ try:
+ os.close(self._fd)
+ finally:
+ self._fd = None
diff --git a/certbot/log.py b/certbot/log.py
index 7660846a6..c7bc867f1 100644
--- a/certbot/log.py
+++ b/certbot/log.py
@@ -131,7 +131,7 @@ def setup_log_file_handler(config, logfile, fmt):
"""
# TODO: logs might contain sensitive data such as contents of the
# private key! #525
- util.make_or_verify_core_dir(
+ util.set_up_core_dir(
config.logs_dir, 0o700, os.geteuid(), config.strict_permissions)
log_file_path = os.path.join(config.logs_dir, logfile)
try:
diff --git a/certbot/main.py b/certbot/main.py
index 023c09aee..50dad8d1e 100644
--- a/certbot/main.py
+++ b/certbot/main.py
@@ -254,12 +254,14 @@ def _ask_user_to_confirm_new_names(config, new_domains, certname, old_domains):
"""
if config.renew_with_new_domains:
return
- msg = ("Confirm that you intend to update certificate {0} "
- "to include domains {1}. Note that it previously "
- "contained domains {2}.".format(
+
+ msg = ("You are updating certificate {0} to include domains: {1}{br}{br}"
+ "It previously included domains: {2}{br}{br}"
+ "Did you intend to make this change?".format(
certname,
- new_domains,
- old_domains))
+ ", ".join(new_domains),
+ ", ".join(old_domains),
+ br=os.linesep))
obj = zope.component.getUtility(interfaces.IDisplay)
if not obj.yesno(msg, "Update cert", "Cancel", default=True):
raise errors.ConfigurationError("Specified mismatched cert name and domains.")
@@ -696,10 +698,10 @@ def renew(config, unused_plugins):
def make_or_verify_needed_dirs(config):
"""Create or verify existence of config and work directories"""
- util.make_or_verify_core_dir(config.config_dir, constants.CONFIG_DIRS_MODE,
- os.geteuid(), config.strict_permissions)
- util.make_or_verify_core_dir(config.work_dir, constants.CONFIG_DIRS_MODE,
- os.geteuid(), config.strict_permissions)
+ util.set_up_core_dir(config.config_dir, constants.CONFIG_DIRS_MODE,
+ os.geteuid(), config.strict_permissions)
+ util.set_up_core_dir(config.work_dir, constants.CONFIG_DIRS_MODE,
+ os.geteuid(), config.strict_permissions)
def set_displayer(config):
diff --git a/certbot/plugins/disco.py b/certbot/plugins/disco.py
index a17f8d7b3..5347ab050 100644
--- a/certbot/plugins/disco.py
+++ b/certbot/plugins/disco.py
@@ -12,6 +12,12 @@ from certbot import constants
from certbot import errors
from certbot import interfaces
+try:
+ from collections import OrderedDict
+except ImportError: # pragma: no cover
+ # OrderedDict was added in Python 2.7
+ from ordereddict import OrderedDict # pylint: disable=import-error
+
logger = logging.getLogger(__name__)
@@ -22,6 +28,11 @@ class PluginEntryPoint(object):
PREFIX_FREE_DISTRIBUTIONS = [
"certbot",
"certbot-apache",
+ "certbot-dns-cloudflare",
+ "certbot-dns-cloudxns",
+ "certbot-dns-digitalocean",
+ "certbot-dns-dnsimple",
+ "certbot-dns-google",
"certbot-nginx",
]
"""Distributions for which prefix will be omitted."""
@@ -168,7 +179,11 @@ class PluginsRegistry(collections.Mapping):
"""Plugins registry."""
def __init__(self, plugins):
- self._plugins = plugins
+ # plugins are sorted so the same order is used between runs.
+ # This prevents deadlock caused by plugins acquiring a lock
+ # and ensures at least one concurrent Certbot instance will run
+ # successfully.
+ self._plugins = OrderedDict(sorted(six.iteritems(plugins)))
@classmethod
def find_all(cls):
diff --git a/certbot/plugins/disco_test.py b/certbot/plugins/disco_test.py
index 6c3c39dca..220b902b3 100644
--- a/certbot/plugins/disco_test.py
+++ b/certbot/plugins/disco_test.py
@@ -1,4 +1,6 @@
"""Tests for certbot.plugins.disco."""
+import functools
+import string
import unittest
import mock
@@ -182,12 +184,17 @@ class PluginEntryPointTest(unittest.TestCase):
class PluginsRegistryTest(unittest.TestCase):
"""Tests for certbot.plugins.disco.PluginsRegistry."""
- def setUp(self):
+ @classmethod
+ def _create_new_registry(cls, plugins):
from certbot.plugins.disco import PluginsRegistry
- self.plugin_ep = mock.MagicMock(name="mock")
+ return PluginsRegistry(plugins)
+
+ def setUp(self):
+ self.plugin_ep = mock.MagicMock()
+ self.plugin_ep.name = "mock"
self.plugin_ep.__hash__.side_effect = TypeError
- self.plugins = {"mock": self.plugin_ep}
- self.reg = PluginsRegistry(self.plugins)
+ self.plugins = {self.plugin_ep.name: self.plugin_ep}
+ self.reg = self._create_new_registry(self.plugins)
def test_find_all(self):
from certbot.plugins.disco import PluginsRegistry
@@ -207,9 +214,8 @@ class PluginsRegistryTest(unittest.TestCase):
self.assertEqual(["mock"], list(self.reg))
def test_len(self):
+ self.assertEqual(0, len(self._create_new_registry({})))
self.assertEqual(1, len(self.reg))
- self.plugins.clear()
- self.assertEqual(0, len(self.reg))
def test_init(self):
self.plugin_ep.init.return_value = "baz"
@@ -217,14 +223,11 @@ class PluginsRegistryTest(unittest.TestCase):
self.plugin_ep.init.assert_called_once_with("bar")
def test_filter(self):
- self.plugins.update({
- "foo": "bar",
- "bar": "foo",
- "baz": "boo",
- })
self.assertEqual(
- {"foo": "bar", "baz": "boo"},
- self.reg.filter(lambda p_ep: str(p_ep).startswith("b")))
+ self.plugins,
+ self.reg.filter(lambda p_ep: p_ep.name.startswith("m")))
+ self.assertEqual(
+ {}, self.reg.filter(lambda p_ep: p_ep.name.startswith("b")))
def test_ifaces(self):
self.plugin_ep.ifaces.return_value = True
@@ -246,6 +249,17 @@ class PluginsRegistryTest(unittest.TestCase):
self.assertEqual(["baz"], self.reg.prepare())
self.plugin_ep.prepare.assert_called_once_with()
+ def test_prepare_order(self):
+ order = []
+ plugins = dict(
+ (c, mock.MagicMock(prepare=functools.partial(order.append, c)))
+ for c in string.ascii_letters)
+ reg = self._create_new_registry(plugins)
+ reg.prepare()
+ # order of prepare calls must be sorted to prevent deadlock
+ # caused by plugins acquiring locks during prepare
+ self.assertEqual(order, sorted(string.ascii_letters))
+
def test_available(self):
self.plugin_ep.available = True
# pylint: disable=protected-access
@@ -265,11 +279,12 @@ class PluginsRegistryTest(unittest.TestCase):
repr(self.reg))
def test_str(self):
+ self.assertEqual("No plugins", str(self._create_new_registry({})))
self.plugin_ep.__str__ = lambda _: "Mock"
- self.plugins["foo"] = "Mock"
- self.assertEqual("Mock\n\nMock", str(self.reg))
- self.plugins.clear()
- self.assertEqual("No plugins", str(self.reg))
+ self.assertEqual("Mock", str(self.reg))
+ plugins = {self.plugin_ep.name: self.plugin_ep, "foo": "Bar"}
+ reg = self._create_new_registry(plugins)
+ self.assertEqual("Bar\n\nMock", str(reg))
if __name__ == "__main__":
diff --git a/certbot/plugins/dns_common.py b/certbot/plugins/dns_common.py
new file mode 100644
index 000000000..f71905d79
--- /dev/null
+++ b/certbot/plugins/dns_common.py
@@ -0,0 +1,323 @@
+"""Common code for DNS Authenticator Plugins."""
+
+import abc
+import logging
+import os
+import stat
+from time import sleep
+
+import configobj
+import zope.interface
+from acme import challenges
+
+from certbot import errors
+from certbot import interfaces
+from certbot.display import ops
+from certbot.display import util as display_util
+from certbot.plugins import common
+
+logger = logging.getLogger(__name__)
+
+
+@zope.interface.implementer(interfaces.IAuthenticator)
+@zope.interface.provider(interfaces.IPluginFactory)
+class DNSAuthenticator(common.Plugin):
+ """Base class for DNS Authenticators"""
+
+ def __init__(self, config, name):
+ super(DNSAuthenticator, self).__init__(config, name)
+
+ self._attempt_cleanup = False
+
+ @classmethod
+ def add_parser_arguments(cls, add, default_propagation_seconds=10): # pylint: disable=arguments-differ
+ add('propagation-seconds',
+ default=default_propagation_seconds,
+ type=int,
+ help='The number of seconds to wait for DNS to propagate before asking the ACME server '
+ 'to verify the DNS record.')
+
+ def get_chall_pref(self, unused_domain): # pylint: disable=missing-docstring,no-self-use
+ return [challenges.DNS01]
+
+ def prepare(self): # pylint: disable=missing-docstring
+ pass
+
+ def perform(self, achalls): # pylint: disable=missing-docstring
+ self._setup_credentials()
+
+ self._attempt_cleanup = True
+
+ responses = []
+ for achall in achalls:
+ domain = achall.domain
+ validation_domain_name = achall.validation_domain_name(domain)
+ validation = achall.validation(achall.account_key)
+
+ self._perform(domain, validation_domain_name, validation)
+ responses.append(achall.response(achall.account_key))
+
+ # DNS updates take time to propagate and checking to see if the update has occurred is not
+ # reliable (the machine this code is running on might be able to see an update before
+ # the ACME server). So: we sleep for a short amount of time we believe to be long enough.
+ logger.info("Waiting %d seconds for DNS changes to propagate",
+ self.conf('propagation-seconds'))
+ sleep(self.conf('propagation-seconds'))
+
+ return responses
+
+ def cleanup(self, achalls): # pylint: disable=missing-docstring
+ if self._attempt_cleanup:
+ for achall in achalls:
+ domain = achall.domain
+ validation_domain_name = achall.validation_domain_name(domain)
+ validation = achall.validation(achall.account_key)
+
+ self._cleanup(domain, validation_domain_name, validation)
+
+ @abc.abstractmethod
+ def _setup_credentials(self): # pragma: no cover
+ """
+ Establish credentials, prompting if necessary.
+ """
+ raise NotImplementedError()
+
+ @abc.abstractmethod
+ def _perform(self, domain, validation_domain_name, validation): # pragma: no cover
+ """
+ Performs a dns-01 challenge by creating a DNS TXT record.
+
+ :param str domain: The domain being validated.
+ :param str validation_domain_name: The validation record domain name.
+ :param str validation: The validation record content.
+ :raises errors.PluginError: If the challenge cannot be performed
+ """
+ raise NotImplementedError()
+
+ @abc.abstractmethod
+ def _cleanup(self, domain, validation_domain_name, validation): # pragma: no cover
+ """
+ Deletes the DNS TXT record which would have been created by `_perform_achall`.
+
+ Fails gracefully if no such record exists.
+
+ :param str domain: The domain being validated.
+ :param str validation_domain_name: The validation record domain name.
+ :param str validation: The validation record content.
+ """
+ raise NotImplementedError()
+
+ def _configure(self, key, label):
+ """
+ Ensure that a configuration value is available.
+
+ If necessary, prompts the user and stores the result.
+
+ :param str key: The configuration key.
+ :param str label: The user-friendly label for this piece of information.
+ """
+
+ configured_value = self.conf(key)
+ if not configured_value:
+ new_value = self._prompt_for_data(label)
+
+ setattr(self.config, self.dest(key), new_value)
+
+ def _configure_file(self, key, label, validator=None):
+ """
+ Ensure that a configuration value is available for a path.
+
+ If necessary, prompts the user and stores the result.
+
+ :param str key: The configuration key.
+ :param str label: The user-friendly label for this piece of information.
+ """
+
+ configured_value = self.conf(key)
+ if not configured_value:
+ new_value = self._prompt_for_file(label, validator)
+
+ setattr(self.config, self.dest(key), os.path.abspath(os.path.expanduser(new_value)))
+
+ def _configure_credentials(self, key, label, required_variables=None):
+ """
+ As `_configure_file`, but for a credential configuration file.
+
+ If necessary, prompts the user and stores the result.
+
+ Always stores absolute paths to avoid issues during renewal.
+
+ :param str key: The configuration key.
+ :param str label: The user-friendly label for this piece of information.
+ :param dict required_variables: Map of variable which must be present to error to display.
+ """
+
+ def __validator(filename):
+ if required_variables:
+ CredentialsConfiguration(filename, self.dest).require(required_variables)
+
+ self._configure_file(key, label, __validator)
+
+ credentials_configuration = CredentialsConfiguration(self.conf(key), self.dest)
+ if required_variables:
+ credentials_configuration.require(required_variables)
+
+ return credentials_configuration
+
+ @staticmethod
+ def _prompt_for_data(label):
+ """
+ Prompt the user for a piece of information.
+
+ :param str label: The user-friendly label for this piece of information.
+ :returns: The user's response (guaranteed non-empty).
+ :rtype: str
+ """
+
+ def __validator(i):
+ if not i:
+ raise errors.PluginError('Please enter your {0}.'.format(label))
+
+ code, response = ops.validated_input(
+ __validator,
+ 'Input your {0}'.format(label),
+ force_interactive=True)
+
+ if code == display_util.OK:
+ return response
+ else:
+ raise errors.PluginError('{0} required to proceed.'.format(label))
+
+ @staticmethod
+ def _prompt_for_file(label, validator=None):
+ """
+ Prompt the user for a path.
+
+ :param str label: The user-friendly label for the file.
+ :param callable validator: A method which will be called to validate the supplied input
+ after it has been validated to be a non-empty path to an existing file. Should throw a
+ `~certbot.errors.PluginError` to indicate any issue.
+ :returns: The user's response (guaranteed to exist).
+ :rtype: str
+ """
+
+ def __validator(filename):
+ if not filename:
+ raise errors.PluginError('Please enter a valid path to your {0}.'.format(label))
+
+ filename = os.path.expanduser(filename)
+
+ validate_file(filename)
+
+ if validator:
+ validator(filename)
+
+ code, response = ops.validated_directory(
+ __validator,
+ 'Input the path to your {0}'.format(label),
+ force_interactive=True)
+
+ if code == display_util.OK:
+ return response
+ else:
+ raise errors.PluginError('{0} required to proceed.'.format(label))
+
+
+class CredentialsConfiguration(object):
+ """Represents a user-supplied filed which stores API credentials."""
+
+ def __init__(self, filename, mapper=lambda x: x):
+ """
+ :param str filename: A path to the configuration file.
+ :param callable mapper: A transformation to apply to configuration key names
+ :raises errors.PluginError: If the file does not exist or is not a valid format.
+ """
+ validate_file_permissions(filename)
+
+ try:
+ self.confobj = configobj.ConfigObj(filename)
+ except configobj.ConfigObjError as e:
+ logger.debug("Error parsing credentials configuration: %s", e, exc_info=True)
+ raise errors.PluginError("Error parsing credentials configuration: {0}".format(e))
+
+ self.mapper = mapper
+
+ def require(self, required_variables):
+ """Ensures that the supplied set of variables are all present in the file.
+
+ :param dict required_variables: Map of variable which must be present to error to display.
+ :raises errors.PluginError: If one or more are missing.
+ """
+ messages = []
+
+ for var in required_variables:
+ if not self._has(var):
+ messages.append('Property "{0}" not found (should be {1}).'
+ .format(self.mapper(var), required_variables[var]))
+ elif not self._get(var):
+ messages.append('Property "{0}" not set (should be {1}).'
+ .format(self.mapper(var), required_variables[var]))
+
+ if messages:
+ raise errors.PluginError(
+ 'Missing {0} in credentials configuration file {1}:\n * {2}'.format(
+ 'property' if len(messages) == 1 else 'properties',
+ self.confobj.filename,
+ '\n * '.join(messages)
+ )
+ )
+
+ def conf(self, var):
+ """Find a configuration value for variable `var`, as transformed by `mapper`.
+
+ :param str var: The variable to get.
+ :returns: The value of the variable.
+ :rtype: str
+ """
+
+ return self._get(var)
+
+ def _has(self, var):
+ return self.mapper(var) in self.confobj
+
+ def _get(self, var):
+ return self.confobj.get(self.mapper(var))
+
+
+def validate_file(filename):
+ """Ensure that the specified file exists."""
+
+ if not os.path.exists(filename):
+ raise errors.PluginError('File not found: {0}'.format(filename))
+
+ if not os.path.isfile(filename):
+ raise errors.PluginError('Path is not a file: {0}'.format(filename))
+
+
+def validate_file_permissions(filename):
+ """Ensure that the specified file exists and warn about unsafe permissions."""
+
+ validate_file(filename)
+
+ permissions = stat.S_IMODE(os.stat(filename).st_mode)
+ if permissions & stat.S_IRWXO:
+ logger.warning('Unsafe permissions on credentials configuration file: %s', filename)
+
+
+def base_domain_name_guesses(domain):
+ """Return a list of progressively less-specific domain names.
+
+ One of these will probably be the domain name known to the DNS provider.
+
+ :Example:
+
+ >>> base_domain_name_guesses('foo.bar.baz.example.com')
+ ['foo.bar.baz.example.com', 'bar.baz.example.com', 'baz.example.com', 'example.com', 'com']
+
+ :param str domain: The domain for which to return guesses.
+ :returns: The a list of less specific domain names.
+ :rtype: list
+ """
+
+ fragments = domain.split('.')
+ return ['.'.join(fragments[i:]) for i in range(0, len(fragments))]
diff --git a/certbot/plugins/dns_common_lexicon.py b/certbot/plugins/dns_common_lexicon.py
new file mode 100644
index 000000000..7a97fc950
--- /dev/null
+++ b/certbot/plugins/dns_common_lexicon.py
@@ -0,0 +1,97 @@
+"""Common code for DNS Authenticator Plugins built on Lexicon."""
+
+import logging
+
+from requests.exceptions import HTTPError, RequestException
+
+from certbot import errors
+from certbot.plugins import dns_common
+
+logger = logging.getLogger(__name__)
+
+
+class LexiconClient(object):
+ """
+ Encapsulates all communication with a DNS provider via Lexicon.
+ """
+
+ def __init__(self):
+ self.provider = None
+
+ def add_txt_record(self, domain, record_name, record_content):
+ """
+ Add a TXT record using the supplied information.
+
+ :param str domain: The domain to use to look up the managed zone.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ :raises errors.PluginError: if an error occurs communicating with the DNS Provider API
+ """
+ self._find_domain_id(domain)
+
+ try:
+ self.provider.create_record(type='TXT', name=record_name, content=record_content)
+ except RequestException as e:
+ logger.debug('Encountered error adding TXT record: %s', e, exc_info=True)
+ raise errors.PluginError('Error adding TXT record: {0}'.format(e))
+
+ def del_txt_record(self, domain, record_name, record_content):
+ """
+ Delete a TXT record using the supplied information.
+
+ :param str domain: The domain to use to look up the managed zone.
+ :param str record_name: The record name (typically beginning with '_acme-challenge.').
+ :param str record_content: The record content (typically the challenge validation).
+ :raises errors.PluginError: if an error occurs communicating with the DNS Provider API
+ """
+ try:
+ self._find_domain_id(domain)
+ except errors.PluginError as e:
+ logger.debug('Encountered error finding domain_id during deletion: %s', e,
+ exc_info=True)
+ return
+
+ try:
+ self.provider.delete_record(type='TXT', name=record_name, content=record_content)
+ except RequestException as e:
+ logger.debug('Encountered error deleting TXT record: %s', e, exc_info=True)
+
+ def _find_domain_id(self, domain):
+ """
+ Find the domain_id for a given domain.
+
+ :param str domain: The domain for which to find the domain_id.
+ :raises errors.PluginError: if the domain_id cannot be found.
+ """
+
+ domain_name_guesses = dns_common.base_domain_name_guesses(domain)
+
+ for domain_name in domain_name_guesses:
+ try:
+ self.provider.options['domain'] = domain_name
+
+ self.provider.authenticate()
+
+ return # If `authenticate` doesn't throw an exception, we've found the right name
+ except HTTPError as e:
+ result = self._handle_http_error(e, domain_name)
+
+ if result:
+ raise result
+ except Exception as e: # pylint: disable=broad-except
+ result = self._handle_general_error(e, domain_name)
+
+ if result:
+ raise result
+
+ raise errors.PluginError('Unable to determine zone identifier for {0} using zone names: {1}'
+ .format(domain, domain_name_guesses))
+
+ def _handle_http_error(self, e, domain_name):
+ return errors.PluginError('Error determining zone identifier for {0}: {1}.'
+ .format(domain_name, e))
+
+ def _handle_general_error(self, e, domain_name):
+ if not str(e).startswith('No domain found'):
+ return errors.PluginError('Unexpected error determining zone identifier for {0}: {1}'
+ .format(domain_name, e))
diff --git a/certbot/plugins/dns_common_lexicon_test.py b/certbot/plugins/dns_common_lexicon_test.py
new file mode 100644
index 000000000..986362ca9
--- /dev/null
+++ b/certbot/plugins/dns_common_lexicon_test.py
@@ -0,0 +1,27 @@
+"""Tests for certbot.plugins.dns_common_lexicon."""
+
+import unittest
+
+import mock
+
+from certbot.plugins import dns_common_lexicon
+from certbot.plugins import dns_test_common_lexicon
+
+
+class LexiconClientTest(unittest.TestCase, dns_test_common_lexicon.BaseLexiconClientTest):
+
+ class _FakeLexiconClient(dns_common_lexicon.LexiconClient):
+ pass
+
+ def setUp(self):
+ super(LexiconClientTest, self).setUp()
+
+ self.client = LexiconClientTest._FakeLexiconClient()
+ self.provider_mock = mock.MagicMock()
+
+ self.client.provider = self.provider_mock
+
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot/plugins/dns_common_test.py b/certbot/plugins/dns_common_test.py
new file mode 100644
index 000000000..9b0f0c875
--- /dev/null
+++ b/certbot/plugins/dns_common_test.py
@@ -0,0 +1,233 @@
+"""Tests for certbot.plugins.dns_common."""
+
+import collections
+import logging
+import os
+import unittest
+
+import mock
+
+from certbot import errors
+from certbot.display import util as display_util
+from certbot.plugins import dns_common
+from certbot.plugins import dns_test_common
+from certbot.tests import util
+
+
+class DNSAuthenticatorTest(util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest):
+ # pylint: disable=protected-access
+
+ class _FakeDNSAuthenticator(dns_common.DNSAuthenticator):
+ _setup_credentials = mock.MagicMock()
+ _perform = mock.MagicMock()
+ _cleanup = mock.MagicMock()
+
+ def __init__(self, *args, **kwargs):
+ # pylint: disable=protected-access
+ super(DNSAuthenticatorTest._FakeDNSAuthenticator, self).__init__(*args, **kwargs)
+
+ def more_info(self): # pylint: disable=missing-docstring,no-self-use
+ return 'A fake authenticator for testing.'
+
+ class _FakeConfig(object):
+ fake_propagation_seconds = 0
+ fake_config_key = 1
+ fake_other_key = None
+ fake_file_path = None
+
+ def setUp(self):
+ super(DNSAuthenticatorTest, self).setUp()
+
+ self.config = DNSAuthenticatorTest._FakeConfig()
+
+ self.auth = DNSAuthenticatorTest._FakeDNSAuthenticator(self.config, "fake")
+
+ def test_perform(self):
+ self.auth.perform([self.achall])
+
+ self.auth._perform.assert_called_once_with(dns_test_common.DOMAIN, mock.ANY, mock.ANY)
+
+ def test_cleanup(self):
+ self.auth._attempt_cleanup = True
+
+ self.auth.cleanup([self.achall])
+
+ self.auth._cleanup.assert_called_once_with(dns_test_common.DOMAIN, mock.ANY, mock.ANY)
+
+ @util.patch_get_utility()
+ def test_prompt(self, mock_get_utility):
+ mock_display = mock_get_utility()
+ mock_display.input.side_effect = ((display_util.OK, "",),
+ (display_util.OK, "value",))
+
+ self.auth._configure("other_key", "")
+ self.assertEqual(self.auth.config.fake_other_key, "value")
+
+ @util.patch_get_utility()
+ def test_prompt_canceled(self, mock_get_utility):
+ mock_display = mock_get_utility()
+ mock_display.input.side_effect = ((display_util.CANCEL, "c",),)
+
+ self.assertRaises(errors.PluginError, self.auth._configure, "other_key", "")
+
+ @util.patch_get_utility()
+ def test_prompt_file(self, mock_get_utility):
+ path = os.path.join(self.tempdir, 'file.ini')
+ open(path, "wb").close()
+
+ mock_display = mock_get_utility()
+ mock_display.directory_select.side_effect = ((display_util.OK, "",),
+ (display_util.OK, "not-a-file.ini",),
+ (display_util.OK, self.tempdir),
+ (display_util.OK, path,))
+
+ self.auth._configure_file("file_path", "")
+ self.assertEqual(self.auth.config.fake_file_path, path)
+
+ @util.patch_get_utility()
+ def test_prompt_file_canceled(self, mock_get_utility):
+ mock_display = mock_get_utility()
+ mock_display.directory_select.side_effect = ((display_util.CANCEL, "c",),)
+
+ self.assertRaises(errors.PluginError, self.auth._configure_file, "file_path", "")
+
+ def test_configure_credentials(self):
+ path = os.path.join(self.tempdir, 'file.ini')
+ dns_test_common.write({"fake_test": "value"}, path)
+ setattr(self.config, "fake_credentials", path)
+
+ credentials = self.auth._configure_credentials("credentials", "", {"test": ""})
+
+ self.assertEqual(credentials.conf("test"), "value")
+
+ @util.patch_get_utility()
+ def test_prompt_credentials(self, mock_get_utility):
+ bad_path = os.path.join(self.tempdir, 'bad-file.ini')
+ dns_test_common.write({"fake_other": "other_value"}, bad_path)
+
+ path = os.path.join(self.tempdir, 'file.ini')
+ dns_test_common.write({"fake_test": "value"}, path)
+ setattr(self.config, "fake_credentials", "")
+
+ mock_display = mock_get_utility()
+ mock_display.directory_select.side_effect = ((display_util.OK, "",),
+ (display_util.OK, "not-a-file.ini",),
+ (display_util.OK, self.tempdir),
+ (display_util.OK, bad_path),
+ (display_util.OK, path,))
+
+ credentials = self.auth._configure_credentials("credentials", "", {"test": ""})
+ self.assertEqual(credentials.conf("test"), "value")
+
+
+class CredentialsConfigurationTest(util.TempDirTestCase):
+ class _MockLoggingHandler(logging.Handler):
+ messages = None
+
+ def __init__(self, *args, **kwargs):
+ self.reset()
+ logging.Handler.__init__(self, *args, **kwargs)
+
+ def emit(self, record):
+ self.messages[record.levelname.lower()].append(record.getMessage())
+
+ def reset(self):
+ """Allows the handler to be reset between tests."""
+ self.messages = collections.defaultdict(list)
+
+ def test_valid_file(self):
+ path = os.path.join(self.tempdir, 'too-permissive-file.ini')
+
+ dns_test_common.write({"test": "value", "other": 1}, path)
+
+ credentials_configuration = dns_common.CredentialsConfiguration(path)
+ self.assertEqual("value", credentials_configuration.conf("test"))
+ self.assertEqual("1", credentials_configuration.conf("other"))
+
+ def test_nonexistent_file(self):
+ path = os.path.join(self.tempdir, 'not-a-file.ini')
+
+ self.assertRaises(errors.PluginError, dns_common.CredentialsConfiguration, path)
+
+ def test_valid_file_with_unsafe_permissions(self):
+ log = self._MockLoggingHandler()
+ dns_common.logger.addHandler(log)
+
+ path = os.path.join(self.tempdir, 'too-permissive-file.ini')
+ open(path, "wb").close()
+
+ dns_common.CredentialsConfiguration(path)
+
+ self.assertEqual(1, len([_ for _ in log.messages['warning'] if _.startswith("Unsafe")]))
+
+
+class CredentialsConfigurationRequireTest(util.TempDirTestCase):
+
+ def setUp(self):
+ super(CredentialsConfigurationRequireTest, self).setUp()
+
+ self.path = os.path.join(self.tempdir, 'file.ini')
+
+ def _write(self, values):
+ dns_test_common.write(values, self.path)
+
+ def test_valid(self):
+ self._write({"test": "value", "other": 1})
+
+ credentials_configuration = dns_common.CredentialsConfiguration(self.path)
+ credentials_configuration.require({"test": "", "other": ""})
+
+ def test_valid_but_extra(self):
+ self._write({"test": "value", "other": 1})
+
+ credentials_configuration = dns_common.CredentialsConfiguration(self.path)
+ credentials_configuration.require({"test": ""})
+
+ def test_valid_empty(self):
+ self._write({})
+
+ credentials_configuration = dns_common.CredentialsConfiguration(self.path)
+ credentials_configuration.require({})
+
+ def test_missing(self):
+ self._write({})
+
+ credentials_configuration = dns_common.CredentialsConfiguration(self.path)
+ self.assertRaises(errors.PluginError, credentials_configuration.require, {"test": ""})
+
+ def test_blank(self):
+ self._write({"test": ""})
+
+ credentials_configuration = dns_common.CredentialsConfiguration(self.path)
+ self.assertRaises(errors.PluginError, credentials_configuration.require, {"test": ""})
+
+ def test_typo(self):
+ self._write({"tets": "typo!"})
+
+ credentials_configuration = dns_common.CredentialsConfiguration(self.path)
+ self.assertRaises(errors.PluginError, credentials_configuration.require, {"test": ""})
+
+
+class DomainNameGuessTest(unittest.TestCase):
+
+ def test_simple_case(self):
+ self.assertTrue(
+ 'example.com' in
+ dns_common.base_domain_name_guesses("example.com")
+ )
+
+ def test_sub_domain(self):
+ self.assertTrue(
+ 'example.com' in
+ dns_common.base_domain_name_guesses("foo.bar.baz.example.com")
+ )
+
+ def test_second_level_domain(self):
+ self.assertTrue(
+ 'example.co.uk' in
+ dns_common.base_domain_name_guesses("foo.bar.baz.example.co.uk")
+ )
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot/plugins/dns_test_common.py b/certbot/plugins/dns_test_common.py
new file mode 100644
index 000000000..d8cd29404
--- /dev/null
+++ b/certbot/plugins/dns_test_common.py
@@ -0,0 +1,63 @@
+"""Base test class for DNS authenticators."""
+
+import os
+
+import configobj
+import mock
+import six
+from acme import challenges
+from acme import jose
+
+from certbot import achallenges
+from certbot.tests import acme_util
+from certbot.tests import util as test_util
+
+DOMAIN = 'example.com'
+KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
+
+
+class BaseAuthenticatorTest(object):
+ """
+ A base test class to reduce duplication between test code for DNS Authenticator Plugins.
+
+ Assumes:
+ * That subclasses also subclass unittest.TestCase
+ * That the authenticator is stored as self.auth
+ """
+
+ achall = achallenges.KeyAuthorizationAnnotatedChallenge(
+ challb=acme_util.DNS01, domain=DOMAIN, account_key=KEY)
+
+ def test_more_info(self):
+ # pylint: disable=no-member
+ self.assertTrue(isinstance(self.auth.more_info(), six.string_types))
+
+ def test_get_chall_pref(self):
+ # pylint: disable=no-member
+ self.assertEqual(self.auth.get_chall_pref(None), [challenges.DNS01])
+
+ def test_parser_arguments(self):
+ m = mock.MagicMock()
+
+ # pylint: disable=no-member
+ self.auth.add_parser_arguments(m)
+
+ m.assert_any_call('propagation-seconds', type=int, default=mock.ANY, help=mock.ANY)
+
+
+def write(values, path):
+ """Write the specified values to a config file.
+
+ :param dict values: A map of values to write.
+ :param str path: Where to write the values.
+ """
+
+ config = configobj.ConfigObj()
+
+ for key in values:
+ config[key] = values[key]
+
+ with open(path, "wb") as f:
+ config.write(outfile=f)
+
+ os.chmod(path, 0o600)
diff --git a/certbot/plugins/dns_test_common_lexicon.py b/certbot/plugins/dns_test_common_lexicon.py
new file mode 100644
index 000000000..f9c5735e8
--- /dev/null
+++ b/certbot/plugins/dns_test_common_lexicon.py
@@ -0,0 +1,128 @@
+"""Base test class for DNS authenticators built on Lexicon."""
+
+import mock
+from acme import jose
+from requests.exceptions import HTTPError, RequestException
+
+from certbot import errors
+from certbot.plugins import dns_test_common
+from certbot.tests import util as test_util
+
+DOMAIN = 'example.com'
+KEY = jose.JWKRSA.load(test_util.load_vector("rsa512_key.pem"))
+
+# These classes are intended to be subclassed/mixed in, so not all members are defined.
+# pylint: disable=no-member
+
+class BaseLexiconAuthenticatorTest(dns_test_common.BaseAuthenticatorTest):
+
+ def test_perform(self):
+ self.auth.perform([self.achall])
+
+ expected = [mock.call.add_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+ def test_cleanup(self):
+ self.auth._attempt_cleanup = True # _attempt_cleanup | pylint: disable=protected-access
+ self.auth.cleanup([self.achall])
+
+ expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY)]
+ self.assertEqual(expected, self.mock_client.mock_calls)
+
+
+class BaseLexiconClientTest(object):
+ DOMAIN_NOT_FOUND = Exception('No domain found')
+ GENERIC_ERROR = RequestException
+ LOGIN_ERROR = HTTPError('400 Client Error: ...')
+ UNKNOWN_LOGIN_ERROR = HTTPError('500 Surprise! Error: ...')
+
+ record_prefix = "_acme-challenge"
+ record_name = record_prefix + "." + DOMAIN
+ record_content = "bar"
+
+ def test_add_txt_record(self):
+ self.client.add_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ self.provider_mock.create_record.assert_called_with(type='TXT',
+ name=self.record_name,
+ content=self.record_content)
+
+ def test_add_txt_record_try_twice_to_find_domain(self):
+ self.provider_mock.authenticate.side_effect = [self.DOMAIN_NOT_FOUND, '']
+
+ self.client.add_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ self.provider_mock.create_record.assert_called_with(type='TXT',
+ name=self.record_name,
+ content=self.record_content)
+
+ def test_add_txt_record_fail_to_find_domain(self):
+ self.provider_mock.authenticate.side_effect = [self.DOMAIN_NOT_FOUND,
+ self.DOMAIN_NOT_FOUND,
+ self.DOMAIN_NOT_FOUND,]
+
+ self.assertRaises(errors.PluginError,
+ self.client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_add_txt_record_fail_to_authenticate(self):
+ self.provider_mock.authenticate.side_effect = self.LOGIN_ERROR
+
+ self.assertRaises(errors.PluginError,
+ self.client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_add_txt_record_fail_to_authenticate_with_unknown_error(self):
+ self.provider_mock.authenticate.side_effect = self.UNKNOWN_LOGIN_ERROR
+
+ self.assertRaises(errors.PluginError,
+ self.client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_add_txt_record_error_finding_domain(self):
+ self.provider_mock.authenticate.side_effect = self.GENERIC_ERROR
+
+ self.assertRaises(errors.PluginError,
+ self.client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_add_txt_record_error_adding_record(self):
+ self.provider_mock.create_record.side_effect = self.GENERIC_ERROR
+
+ self.assertRaises(errors.PluginError,
+ self.client.add_txt_record,
+ DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record(self):
+ self.client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ self.provider_mock.delete_record.assert_called_with(type='TXT',
+ name=self.record_name,
+ content=self.record_content)
+
+ def test_del_txt_record_fail_to_find_domain(self):
+ self.provider_mock.authenticate.side_effect = [self.DOMAIN_NOT_FOUND,
+ self.DOMAIN_NOT_FOUND,
+ self.DOMAIN_NOT_FOUND, ]
+
+ self.client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record_fail_to_authenticate(self):
+ self.provider_mock.authenticate.side_effect = self.LOGIN_ERROR
+
+ self.client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record_fail_to_authenticate_with_unknown_error(self):
+ self.provider_mock.authenticate.side_effect = self.UNKNOWN_LOGIN_ERROR
+
+ self.client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record_error_finding_domain(self):
+ self.provider_mock.authenticate.side_effect = self.GENERIC_ERROR
+
+ self.client.del_txt_record(DOMAIN, self.record_name, self.record_content)
+
+ def test_del_txt_record_error_deleting_record(self):
+ self.provider_mock.delete_record.side_effect = self.GENERIC_ERROR
+
+ self.client.del_txt_record(DOMAIN, self.record_name, self.record_content)
diff --git a/certbot/plugins/manual.py b/certbot/plugins/manual.py
index 1163e7e7e..25e2564fa 100644
--- a/certbot/plugins/manual.py
+++ b/certbot/plugins/manual.py
@@ -42,7 +42,7 @@ Please deploy a DNS TXT record under the name
{validation}
-Once this is deployed,"""
+Before continuing, verify the record is deployed."""
_HTTP_INSTRUCTIONS = """\
Make sure your web server displays the following content at
{uri} before continuing:
diff --git a/certbot/plugins/selection.py b/certbot/plugins/selection.py
index d138001e6..89fa0ab7b 100644
--- a/certbot/plugins/selection.py
+++ b/certbot/plugins/selection.py
@@ -133,7 +133,8 @@ def choose_plugin(prepared, question):
else:
return None
-noninstaller_plugins = ["webroot", "manual", "standalone"]
+noninstaller_plugins = ["webroot", "manual", "standalone", "dns-cloudflare", "dns-cloudxns",
+ "dns-digitalocean", "dns-dnsimple", "dns-google"]
def record_chosen_plugins(config, plugins, auth, inst):
"Update the config entries to reflect the plugins we actually selected."
@@ -237,6 +238,16 @@ def cli_plugin_requests(config):
req_auth = set_configurator(req_auth, "webroot")
if config.manual:
req_auth = set_configurator(req_auth, "manual")
+ if config.dns_cloudflare:
+ req_auth = set_configurator(req_auth, "dns-cloudflare")
+ if config.dns_cloudxns:
+ req_auth = set_configurator(req_auth, "dns-cloudxns")
+ if config.dns_digitalocean:
+ req_auth = set_configurator(req_auth, "dns-digitalocean")
+ if config.dns_dnsimple:
+ req_auth = set_configurator(req_auth, "dns-dnsimple")
+ if config.dns_google:
+ req_auth = set_configurator(req_auth, "dns-google")
logger.debug("Requested authenticator %s and installer %s", req_auth, req_inst)
return req_auth, req_inst
diff --git a/certbot/plugins/standalone.py b/certbot/plugins/standalone.py
index 0c15930a3..68200666c 100644
--- a/certbot/plugins/standalone.py
+++ b/certbot/plugins/standalone.py
@@ -3,7 +3,6 @@ import argparse
import collections
import logging
import socket
-import sys
import threading
import OpenSSL
@@ -13,7 +12,6 @@ import zope.interface
from acme import challenges
from acme import standalone as acme_standalone
-from certbot import cli
from certbot import errors
from certbot import interfaces
@@ -114,39 +112,57 @@ class ServerManager(object):
SUPPORTED_CHALLENGES = [challenges.TLSSNI01, challenges.HTTP01]
-def supported_challenges_validator(data):
- """Supported challenges validator for the `argparse`.
+class SupportedChallengesAction(argparse.Action):
+ """Action class for parsing standalone_supported_challenges."""
- It should be passed as `type` argument to `add_argument`.
+ def __call__(self, parser, namespace, values, option_string=None):
+ logger.warning(
+ "The standalone specific supported challenges flag is "
+ "deprecated. Please use the --preferred-challenges flag "
+ "instead.")
+ converted_values = self._convert_and_validate(values)
+ namespace.standalone_supported_challenges = converted_values
- """
- if cli.set_by_cli("standalone_supported_challenges"):
- sys.stderr.write(
- "WARNING: The standalone specific "
- "supported challenges flag is deprecated.\n"
- "Please use the --preferred-challenges flag instead.\n")
- challs = data.split(",")
+ def _convert_and_validate(self, data):
+ """Validate the value of supported challenges provided by the user.
- # tls-sni-01 was dvsni during private beta
- if "dvsni" in challs:
- logger.info("Updating legacy standalone_supported_challenges value")
- challs = [challenges.TLSSNI01.typ if chall == "dvsni" else chall
- for chall in challs]
- data = ",".join(challs)
+ References to "dvsni" are automatically converted to "tls-sni-01".
- unrecognized = [name for name in challs
- if name not in challenges.Challenge.TYPES]
- if unrecognized:
- raise argparse.ArgumentTypeError(
- "Unrecognized challenges: {0}".format(", ".join(unrecognized)))
+ :param str data: comma delimited list of challenge types
- choices = set(chall.typ for chall in SUPPORTED_CHALLENGES)
- if not set(challs).issubset(choices):
- raise argparse.ArgumentTypeError(
- "Plugin does not support the following (valid) "
- "challenges: {0}".format(", ".join(set(challs) - choices)))
+ :returns: validated and converted list of challenge types
+ :rtype: str
- return data
+ """
+ challs = data.split(",")
+
+ # tls-sni-01 was dvsni during private beta
+ if "dvsni" in challs:
+ logger.info(
+ "Updating legacy standalone_supported_challenges value")
+ challs = [challenges.TLSSNI01.typ if chall == "dvsni" else chall
+ for chall in challs]
+ data = ",".join(challs)
+
+ unrecognized = [name for name in challs
+ if name not in challenges.Challenge.TYPES]
+
+ # argparse.ArgumentErrors raised out of argparse.Action objects
+ # are caught by argparse which prints usage information and the
+ # error that occurred before calling sys.exit.
+ if unrecognized:
+ raise argparse.ArgumentError(
+ self,
+ "Unrecognized challenges: {0}".format(", ".join(unrecognized)))
+
+ choices = set(chall.typ for chall in SUPPORTED_CHALLENGES)
+ if not set(challs).issubset(choices):
+ raise argparse.ArgumentError(
+ self,
+ "Plugin does not support the following (valid) "
+ "challenges: {0}".format(", ".join(set(challs) - choices)))
+
+ return data
@zope.interface.implementer(interfaces.IAuthenticator)
@@ -184,7 +200,7 @@ class Authenticator(common.Plugin):
def add_parser_arguments(cls, add):
add("supported-challenges",
help=argparse.SUPPRESS,
- type=supported_challenges_validator,
+ action=SupportedChallengesAction,
default=",".join(chall.typ for chall in SUPPORTED_CHALLENGES))
@property
diff --git a/certbot/plugins/standalone_test.py b/certbot/plugins/standalone_test.py
index 83e0fcf7f..65d16c2f2 100644
--- a/certbot/plugins/standalone_test.py
+++ b/certbot/plugins/standalone_test.py
@@ -62,28 +62,26 @@ class ServerManagerTest(unittest.TestCase):
self.assertEqual(self.mgr.running(), {})
-class SupportedChallengesValidatorTest(unittest.TestCase):
- """Tests for plugins.standalone.supported_challenges_validator."""
+class SupportedChallengesActionTest(unittest.TestCase):
+ """Tests for plugins.standalone.SupportedChallengesAction."""
+
+ def _call(self, value):
+ with mock.patch("certbot.plugins.standalone.logger") as mock_logger:
+ # stderr is mocked to prevent potential argparse error
+ # output from cluttering test output
+ with mock.patch("sys.stderr"):
+ config = self.parser.parse_args([self.flag, value])
+
+ self.assertTrue(mock_logger.warning.called)
+ return getattr(config, self.dest)
def setUp(self):
- self.set_by_cli_patch = mock.patch(
- "certbot.plugins.standalone.cli.set_by_cli")
- self.stderr_patch = mock.patch("certbot.plugins.standalone.sys.stderr")
+ self.flag = "--standalone-supported-challenges"
+ self.dest = self.flag[2:].replace("-", "_")
+ self.parser = argparse.ArgumentParser()
- self.set_by_cli_patch.start().return_value = True
- self.stderr = self.stderr_patch.start()
-
- def tearDown(self):
- self.set_by_cli_patch.stop()
- self.stderr_patch.stop()
-
- def _call(self, data):
- from certbot.plugins.standalone import (
- supported_challenges_validator)
- return_value = supported_challenges_validator(data)
- self.assertTrue(self.stderr.write.called) # pylint: disable=no-member
- self.stderr.write.reset_mock() # pylint: disable=no-member
- return return_value
+ from certbot.plugins.standalone import SupportedChallengesAction
+ self.parser.add_argument(self.flag, action=SupportedChallengesAction)
def test_correct(self):
self.assertEqual("tls-sni-01", self._call("tls-sni-01"))
@@ -93,10 +91,10 @@ class SupportedChallengesValidatorTest(unittest.TestCase):
def test_unrecognized(self):
assert "foo" not in challenges.Challenge.TYPES
- self.assertRaises(argparse.ArgumentTypeError, self._call, "foo")
+ self.assertRaises(SystemExit, self._call, "foo")
def test_not_subset(self):
- self.assertRaises(argparse.ArgumentTypeError, self._call, "dns")
+ self.assertRaises(SystemExit, self._call, "dns")
def test_dvsni(self):
self.assertEqual("tls-sni-01", self._call("dvsni"))
diff --git a/certbot/reverter.py b/certbot/reverter.py
index 32355782e..34feafc7e 100644
--- a/certbot/reverter.py
+++ b/certbot/reverter.py
@@ -491,7 +491,7 @@ class Reverter(object):
else:
logger.warning(
"File: %s - Could not be found to be deleted %s - "
- "LE probably shut down unexpectedly",
+ "Certbot probably shut down unexpectedly",
os.linesep, path)
except (IOError, OSError):
logger.fatal(
diff --git a/certbot/storage.py b/certbot/storage.py
index c35a268e3..4f167d4ea 100644
--- a/certbot/storage.py
+++ b/certbot/storage.py
@@ -4,6 +4,7 @@ import glob
import logging
import os
import re
+import stat
import configobj
import parsedatetime
@@ -117,10 +118,20 @@ def write_renewal_config(o_filename, n_filename, archive_dir, target, relevant_d
# TODO: add human-readable comments explaining other available
# parameters
logger.debug("Writing new config %s.", n_filename)
+
+ # Ensure that the file exists
+ open(n_filename, 'a').close()
+
+ # Copy permissions from the old version of the file, if it exists.
+ if os.path.exists(o_filename):
+ current_permissions = stat.S_IMODE(os.lstat(o_filename).st_mode)
+ os.chmod(n_filename, current_permissions)
+
with open(n_filename, "wb") as f:
config.write(outfile=f)
return config
+
def rename_renewal_config(prev_name, new_name, cli_config):
"""Renames cli_config.certname's config to cli_config.new_certname.
diff --git a/certbot/tests/crypto_util_test.py b/certbot/tests/crypto_util_test.py
index c83ad96b1..c678dc501 100644
--- a/certbot/tests/crypto_util_test.py
+++ b/certbot/tests/crypto_util_test.py
@@ -163,7 +163,7 @@ class ImportCSRFileTest(unittest.TestCase):
util.CSR(file=csrfile,
data=data_pem,
form="pem"),
- ["example.com"],),
+ ["Example.com"],),
self._call(csrfile, data))
def test_pem_csr(self):
@@ -175,7 +175,7 @@ class ImportCSRFileTest(unittest.TestCase):
util.CSR(file=csrfile,
data=data,
form="pem"),
- ["example.com"],),
+ ["Example.com"],),
self._call(csrfile, data))
def test_bad_csr(self):
@@ -293,5 +293,14 @@ class NotAfterTest(unittest.TestCase):
'2014-12-18T22:34:45+00:00')
+class Sha256sumTest(unittest.TestCase):
+ """Tests for certbot.crypto_util.notAfter"""
+
+ def test_sha256sum(self):
+ from certbot.crypto_util import sha256sum
+ self.assertEqual(sha256sum(CERT_PATH),
+ '914ffed8daf9e2c99d90ac95c77d54f32cbd556672facac380f0c063498df84e')
+
+
if __name__ == '__main__':
unittest.main() # pragma: no cover
diff --git a/certbot/tests/display/util_test.py b/certbot/tests/display/util_test.py
index f4d69b50d..1dfc21c30 100644
--- a/certbot/tests/display/util_test.py
+++ b/certbot/tests/display/util_test.py
@@ -1,8 +1,11 @@
"""Test :mod:`certbot.display.util`."""
import inspect
import os
+import socket
+import tempfile
import unittest
+import six
import mock
from certbot import errors
@@ -15,6 +18,41 @@ CHOICES = [("First", "Description1"), ("Second", "Description2")]
TAGS = ["tag1", "tag2", "tag3"]
TAGS_CHOICES = [("1", "tag1"), ("2", "tag2"), ("3", "tag3")]
+
+class InputWithTimeoutTest(unittest.TestCase):
+ """Tests for certbot.display.util.input_with_timeout."""
+ @classmethod
+ def _call(cls, *args, **kwargs):
+ from certbot.display.util import input_with_timeout
+ return input_with_timeout(*args, **kwargs)
+
+ def test_eof(self):
+ with tempfile.TemporaryFile("r+") as f:
+ with mock.patch("certbot.display.util.sys.stdin", new=f):
+ self.assertRaises(EOFError, self._call)
+
+ def test_input(self, prompt=None):
+ expected = "foo bar"
+ stdin = six.StringIO(expected + "\n")
+ with mock.patch("certbot.display.util.select.select") as mock_select:
+ mock_select.return_value = ([stdin], [], [],)
+ self.assertEqual(self._call(prompt), expected)
+
+ @mock.patch("certbot.display.util.sys.stdout")
+ def test_input_with_prompt(self, mock_stdout):
+ prompt = "test prompt: "
+ self.test_input(prompt)
+ mock_stdout.write.assert_called_once_with(prompt)
+ mock_stdout.flush.assert_called_once_with()
+
+ def test_timeout(self):
+ stdin = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ stdin.bind(('', 0))
+ stdin.listen(1)
+ with mock.patch("certbot.display.util.sys.stdin", stdin):
+ self.assertRaises(errors.Error, self._call, timeout=0.001)
+
+
class FileOutputDisplayTest(unittest.TestCase):
"""Test stdout display.
@@ -35,7 +73,8 @@ class FileOutputDisplayTest(unittest.TestCase):
self.assertTrue("message" in string)
def test_notification_pause(self):
- with mock.patch("six.moves.input", return_value="enter"):
+ input_with_timeout = "certbot.display.util.input_with_timeout"
+ with mock.patch(input_with_timeout, return_value="enter"):
self.displayer.notification("message", force_interactive=True)
self.assertTrue("message" in self.mock_stdout.write.call_args[0][0])
@@ -72,13 +111,15 @@ class FileOutputDisplayTest(unittest.TestCase):
self.assertEqual(result, (display_util.OK, default))
def test_input_cancel(self):
- with mock.patch("six.moves.input", return_value="c"):
+ input_with_timeout = "certbot.display.util.input_with_timeout"
+ with mock.patch(input_with_timeout, return_value="c"):
code, _ = self.displayer.input("message", force_interactive=True)
self.assertTrue(code, display_util.CANCEL)
def test_input_normal(self):
- with mock.patch("six.moves.input", return_value="domain.com"):
+ input_with_timeout = "certbot.display.util.input_with_timeout"
+ with mock.patch(input_with_timeout, return_value="domain.com"):
code, input_ = self.displayer.input("message", force_interactive=True)
self.assertEqual(code, display_util.OK)
@@ -104,23 +145,24 @@ class FileOutputDisplayTest(unittest.TestCase):
self.displayer.input, "msg", cli_flag="--flag")
def test_yesno(self):
- with mock.patch("six.moves.input", return_value="Yes"):
+ input_with_timeout = "certbot.display.util.input_with_timeout"
+ with mock.patch(input_with_timeout, return_value="Yes"):
self.assertTrue(self.displayer.yesno(
"message", force_interactive=True))
- with mock.patch("six.moves.input", return_value="y"):
+ with mock.patch(input_with_timeout, return_value="y"):
self.assertTrue(self.displayer.yesno(
"message", force_interactive=True))
- with mock.patch("six.moves.input", side_effect=["maybe", "y"]):
+ with mock.patch(input_with_timeout, side_effect=["maybe", "y"]):
self.assertTrue(self.displayer.yesno(
"message", force_interactive=True))
- with mock.patch("six.moves.input", return_value="No"):
+ with mock.patch(input_with_timeout, return_value="No"):
self.assertFalse(self.displayer.yesno(
"message", force_interactive=True))
- with mock.patch("six.moves.input", side_effect=["cancel", "n"]):
+ with mock.patch(input_with_timeout, side_effect=["cancel", "n"]):
self.assertFalse(self.displayer.yesno(
"message", force_interactive=True))
- with mock.patch("six.moves.input", return_value="a"):
+ with mock.patch(input_with_timeout, return_value="a"):
self.assertTrue(self.displayer.yesno(
"msg", yes_label="Agree", force_interactive=True))
@@ -128,7 +170,7 @@ class FileOutputDisplayTest(unittest.TestCase):
self.assertTrue(self._force_noninteractive(
self.displayer.yesno, "message", default=True))
- @mock.patch("certbot.display.util.six.moves.input")
+ @mock.patch("certbot.display.util.input_with_timeout")
def test_checklist_valid(self, mock_input):
mock_input.return_value = "2 1"
code, tag_list = self.displayer.checklist(
@@ -136,21 +178,21 @@ class FileOutputDisplayTest(unittest.TestCase):
self.assertEqual(
(code, set(tag_list)), (display_util.OK, set(["tag1", "tag2"])))
- @mock.patch("certbot.display.util.six.moves.input")
+ @mock.patch("certbot.display.util.input_with_timeout")
def test_checklist_empty(self, mock_input):
mock_input.return_value = ""
code, tag_list = self.displayer.checklist("msg", TAGS, force_interactive=True)
self.assertEqual(
(code, set(tag_list)), (display_util.OK, set(["tag1", "tag2", "tag3"])))
- @mock.patch("certbot.display.util.six.moves.input")
+ @mock.patch("certbot.display.util.input_with_timeout")
def test_checklist_miss_valid(self, mock_input):
mock_input.side_effect = ["10", "tag1 please", "1"]
ret = self.displayer.checklist("msg", TAGS, force_interactive=True)
self.assertEqual(ret, (display_util.OK, ["tag1"]))
- @mock.patch("certbot.display.util.six.moves.input")
+ @mock.patch("certbot.display.util.input_with_timeout")
def test_checklist_miss_quit(self, mock_input):
mock_input.side_effect = ["10", "c"]
@@ -182,7 +224,7 @@ class FileOutputDisplayTest(unittest.TestCase):
self.displayer._scrub_checklist_input(list_, TAGS))
self.assertEqual(set_tags, exp[i])
- @mock.patch("certbot.display.util.six.moves.input")
+ @mock.patch("certbot.display.util.input_with_timeout")
def test_directory_select(self, mock_input):
# pylint: disable=star-args
args = ["msg", "/var/www/html", "--flag", True]
@@ -246,11 +288,12 @@ class FileOutputDisplayTest(unittest.TestCase):
def test_get_valid_int_ans_valid(self):
# pylint: disable=protected-access
- with mock.patch("six.moves.input", return_value="1"):
+ input_with_timeout = "certbot.display.util.input_with_timeout"
+ with mock.patch(input_with_timeout, return_value="1"):
self.assertEqual(
self.displayer._get_valid_int_ans(1), (display_util.OK, 1))
ans = "2"
- with mock.patch("six.moves.input", return_value=ans):
+ with mock.patch(input_with_timeout, return_value=ans):
self.assertEqual(
self.displayer._get_valid_int_ans(3),
(display_util.OK, int(ans)))
@@ -262,8 +305,9 @@ class FileOutputDisplayTest(unittest.TestCase):
["4", "one", "C"],
["c"],
]
+ input_with_timeout = "certbot.display.util.input_with_timeout"
for ans in answers:
- with mock.patch("six.moves.input", side_effect=ans):
+ with mock.patch(input_with_timeout, side_effect=ans):
self.assertEqual(
self.displayer._get_valid_int_ans(3),
(display_util.CANCEL, -1))
diff --git a/certbot/tests/lock_test.py b/certbot/tests/lock_test.py
new file mode 100644
index 000000000..e1a4f8c8a
--- /dev/null
+++ b/certbot/tests/lock_test.py
@@ -0,0 +1,116 @@
+"""Tests for certbot.lock."""
+import functools
+import multiprocessing
+import os
+import unittest
+
+import mock
+
+from certbot import errors
+from certbot.tests import util as test_util
+
+
+class LockDirTest(test_util.TempDirTestCase):
+ """Tests for certbot.lock.lock_dir."""
+ @classmethod
+ def _call(cls, *args, **kwargs):
+ from certbot.lock import lock_dir
+ return lock_dir(*args, **kwargs)
+
+ def test_it(self):
+ assert_raises = functools.partial(
+ self.assertRaises, errors.LockError, self._call, self.tempdir)
+ lock_path = os.path.join(self.tempdir, '.certbot.lock')
+ test_util.lock_and_call(assert_raises, lock_path)
+
+
+class LockFileTest(test_util.TempDirTestCase):
+ """Tests for certbot.lock.LockFile."""
+ @classmethod
+ def _call(cls, *args, **kwargs):
+ from certbot.lock import LockFile
+ return LockFile(*args, **kwargs)
+
+ def setUp(self):
+ super(LockFileTest, self).setUp()
+ self.lock_path = os.path.join(self.tempdir, 'test.lock')
+
+ def test_acquire_without_deletion(self):
+ # acquire the lock in another process but don't delete the file
+ child = multiprocessing.Process(target=self._call,
+ args=(self.lock_path,))
+ child.start()
+ child.join()
+ self.assertEqual(child.exitcode, 0)
+ self.assertTrue(os.path.exists(self.lock_path))
+
+ # Test we're still able to properly acquire and release the lock
+ self.test_removed()
+
+ def test_contention(self):
+ assert_raises = functools.partial(
+ self.assertRaises, errors.LockError, self._call, self.lock_path)
+ test_util.lock_and_call(assert_raises, self.lock_path)
+
+ def test_locked_repr(self):
+ lock_file = self._call(self.lock_path)
+ locked_repr = repr(lock_file)
+ self._test_repr_common(lock_file, locked_repr)
+ self.assertTrue('acquired' in locked_repr)
+
+ def test_released_repr(self):
+ lock_file = self._call(self.lock_path)
+ lock_file.release()
+ released_repr = repr(lock_file)
+ self._test_repr_common(lock_file, released_repr)
+ self.assertTrue('released' in released_repr)
+
+ def _test_repr_common(self, lock_file, lock_repr):
+ self.assertTrue(lock_file.__class__.__name__ in lock_repr)
+ self.assertTrue(self.lock_path in lock_repr)
+
+ def test_race(self):
+ should_delete = [True, False]
+ stat = os.stat
+
+ def delete_and_stat(path):
+ """Wrap os.stat and maybe delete the file first."""
+ if path == self.lock_path and should_delete.pop(0):
+ os.remove(path)
+ return stat(path)
+
+ with mock.patch('certbot.lock.os.stat') as mock_stat:
+ mock_stat.side_effect = delete_and_stat
+ self._call(self.lock_path)
+ self.assertFalse(should_delete)
+
+ def test_removed(self):
+ lock_file = self._call(self.lock_path)
+ lock_file.release()
+ self.assertFalse(os.path.exists(self.lock_path))
+
+ @mock.patch('certbot.lock.fcntl.lockf')
+ def test_unexpected_lockf_err(self, mock_lockf):
+ msg = 'hi there'
+ mock_lockf.side_effect = IOError(msg)
+ try:
+ self._call(self.lock_path)
+ except IOError as err:
+ self.assertTrue(msg in str(err))
+ else: # pragma: no cover
+ self.fail('IOError not raised')
+
+ @mock.patch('certbot.lock.os.stat')
+ def test_unexpected_stat_err(self, mock_stat):
+ msg = 'hi there'
+ mock_stat.side_effect = OSError(msg)
+ try:
+ self._call(self.lock_path)
+ except OSError as err:
+ self.assertTrue(msg in str(err))
+ else: # pragma: no cover
+ self.fail('OSError not raised')
+
+
+if __name__ == "__main__":
+ unittest.main() # pragma: no cover
diff --git a/certbot/tests/main_test.py b/certbot/tests/main_test.py
index 562c4bb9d..7c2016178 100644
--- a/certbot/tests/main_test.py
+++ b/certbot/tests/main_test.py
@@ -410,8 +410,6 @@ class MainTest(test_util.TempDirTestCase): # pylint: disable=too-many-public-me
finally:
output = toy_out.getvalue() or toy_err.getvalue()
self.assertTrue("certbot" in output, "Output is {0}".format(output))
- toy_out.close()
- toy_err.close()
def _cli_missing_flag(self, args, message):
"Ensure that a particular error raises a missing cli flag error containing message"
@@ -792,12 +790,12 @@ class MainTest(test_util.TempDirTestCase): # pylint: disable=too-many-public-me
print(lf.read())
def test_renew_verb(self):
- test_util.make_lineage(self, 'sample-renewal.conf')
+ test_util.make_lineage(self.config_dir, 'sample-renewal.conf')
args = ["renew", "--dry-run", "-tvv"]
self._test_renewal_common(True, [], args=args, should_renew=True)
def test_quiet_renew(self):
- test_util.make_lineage(self, 'sample-renewal.conf')
+ test_util.make_lineage(self.config_dir, 'sample-renewal.conf')
args = ["renew", "--dry-run"]
_, _, stdout = self._test_renewal_common(True, [], args=args, should_renew=True)
out = stdout.getvalue()
@@ -809,13 +807,13 @@ class MainTest(test_util.TempDirTestCase): # pylint: disable=too-many-public-me
self.assertEqual("", out)
def test_renew_hook_validation(self):
- test_util.make_lineage(self, 'sample-renewal.conf')
+ test_util.make_lineage(self.config_dir, 'sample-renewal.conf')
args = ["renew", "--dry-run", "--post-hook=no-such-command"]
self._test_renewal_common(True, [], args=args, should_renew=False,
error_expected=True)
def test_renew_no_hook_validation(self):
- test_util.make_lineage(self, 'sample-renewal.conf')
+ test_util.make_lineage(self.config_dir, 'sample-renewal.conf')
args = ["renew", "--dry-run", "--post-hook=no-such-command",
"--disable-hook-validation"]
with mock.patch("certbot.hooks.post_hook"):
@@ -825,7 +823,8 @@ class MainTest(test_util.TempDirTestCase): # pylint: disable=too-many-public-me
@mock.patch("certbot.cli.set_by_cli")
def test_ancient_webroot_renewal_conf(self, mock_set_by_cli):
mock_set_by_cli.return_value = False
- rc_path = test_util.make_lineage(self, 'sample-renewal-ancient.conf')
+ rc_path = test_util.make_lineage(
+ self.config_dir, 'sample-renewal-ancient.conf')
args = mock.MagicMock(account=None, config_dir=self.config_dir,
logs_dir=self.logs_dir, work_dir=self.work_dir,
email=None, webroot_path=None)
@@ -846,7 +845,7 @@ class MainTest(test_util.TempDirTestCase): # pylint: disable=too-many-public-me
self._test_renewal_common(False, [], args=args, should_renew=False, error_expected=True)
def test_renew_with_certname(self):
- test_util.make_lineage(self, 'sample-renewal.conf')
+ test_util.make_lineage(self.config_dir, 'sample-renewal.conf')
self._test_renewal_common(True, [], should_renew=True,
args=['renew', '--dry-run', '--cert-name', 'sample-renewal'])
diff --git a/certbot/tests/renewal_test.py b/certbot/tests/renewal_test.py
index de3efe39c..869e6b104 100644
--- a/certbot/tests/renewal_test.py
+++ b/certbot/tests/renewal_test.py
@@ -21,7 +21,8 @@ class RenewalTest(util.TempDirTestCase):
@mock.patch('certbot.cli.set_by_cli')
def test_ancient_webroot_renewal_conf(self, mock_set_by_cli):
mock_set_by_cli.return_value = False
- rc_path = util.make_lineage(self, 'sample-renewal-ancient.conf')
+ rc_path = util.make_lineage(
+ self.config_dir, 'sample-renewal-ancient.conf')
args = mock.MagicMock(account=None, config_dir=self.config_dir,
logs_dir="logs", work_dir="work",
email=None, webroot_path=None)
diff --git a/certbot/tests/storage_test.py b/certbot/tests/storage_test.py
index d8fe98536..e6e2b25ff 100644
--- a/certbot/tests/storage_test.py
+++ b/certbot/tests/storage_test.py
@@ -3,6 +3,7 @@
import datetime
import os
import shutil
+import stat
import unittest
import configobj
@@ -758,13 +759,16 @@ class RenewableCertTests(BaseRenewableCertTest):
with open(temp, "w") as f:
f.write("[renewalparams]\nuseful = value # A useful value\n"
"useless = value # Not needed\n")
+ os.chmod(temp, 0o640)
target = {}
for x in ALL_FOUR:
target[x] = "somewhere"
archive_dir = "the_archive"
relevant_data = {"useful": "new_value"}
+
from certbot import storage
storage.write_renewal_config(temp, temp2, archive_dir, target, relevant_data)
+
with open(temp2, "r") as f:
content = f.read()
# useful value was updated
@@ -775,6 +779,9 @@ class RenewableCertTests(BaseRenewableCertTest):
self.assertTrue("useless" not in content)
# check version was stored
self.assertTrue("version = {0}".format(certbot.__version__) in content)
+ # ensure permissions are copied
+ self.assertEqual(stat.S_IMODE(os.lstat(temp).st_mode),
+ stat.S_IMODE(os.lstat(temp2).st_mode))
def test_update_symlinks(self):
from certbot import storage
diff --git a/certbot/tests/testdata/csr.der b/certbot/tests/testdata/csr.der
index 22900a612..5c03f3a11 100644
Binary files a/certbot/tests/testdata/csr.der and b/certbot/tests/testdata/csr.der differ
diff --git a/certbot/tests/testdata/csr.pem b/certbot/tests/testdata/csr.pem
index b6818e39d..c62224ca7 100644
--- a/certbot/tests/testdata/csr.pem
+++ b/certbot/tests/testdata/csr.pem
@@ -1,10 +1,8 @@
-----BEGIN CERTIFICATE REQUEST-----
-MIIBXTCCAQcCAQAweTELMAkGA1UEBhMCVVMxETAPBgNVBAgMCE1pY2hpZ2FuMRIw
-EAYDVQQHDAlBbm4gQXJib3IxDDAKBgNVBAoMA0VGRjEfMB0GA1UECwwWVW5pdmVy
-c2l0eSBvZiBNaWNoaWdhbjEUMBIGA1UEAwwLZXhhbXBsZS5jb20wXDANBgkqhkiG
-9w0BAQEFAANLADBIAkEArHVztFHtH92ucFJD/N/HW9AsdRsUuHUBBBDlHwNlRd3f
-p580rv2+6QWE30cWgdmJS86ObRz6lUTor4R0T+3C5QIDAQABoCkwJwYJKoZIhvcN
-AQkOMRowGDAWBgNVHREEDzANggtleGFtcGxlLmNvbTANBgkqhkiG9w0BAQsFAANB
-AHJH/O6BtC9aGzEVCMGOZ7z9iIRHWSzr9x/bOzn7hLwsbXPAgO1QxEwL+X+4g20G
-n9XBE1N9W6HCIEut2d8wACg=
+MIIBFTCBwAIBADBbMQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEh
+MB8GA1UECgwYSW50ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMRQwEgYDVQQDDAtFeGFt
+cGxlLmNvbTBcMA0GCSqGSIb3DQEBAQUAA0sAMEgCQQCsdXO0Ue0f3a5wUkP838db
+0Cx1GxS4dQEEEOUfA2VF3d+nnzSu/b7pBYTfRxaB2YlLzo5tHPqVROivhHRP7cLl
+AgMBAAGgADANBgkqhkiG9w0BAQsFAANBAAceUlq4La8qaiK0DeDP3M19BIVzMmz2
+oemG2fOvPiwNCB90ctSWQ6bMpUMV85ShcFi31C5vlntPfztehhq6YuE=
-----END CERTIFICATE REQUEST-----
diff --git a/certbot/tests/util.py b/certbot/tests/util.py
index d58834335..76e3d5846 100644
--- a/certbot/tests/util.py
+++ b/certbot/tests/util.py
@@ -3,6 +3,7 @@
.. warning:: This module is not part of the public API.
"""
+import multiprocessing
import os
import pkg_resources
import shutil
@@ -13,12 +14,14 @@ from cryptography.hazmat.backends import default_backend
from cryptography.hazmat.primitives import serialization
import mock
import OpenSSL
+from six.moves import reload_module # pylint: disable=import-error
from acme import jose
from certbot import constants
from certbot import interfaces
from certbot import storage
+from certbot import util
from certbot.display import util as display_util
@@ -106,12 +109,13 @@ def skip_unless(condition, reason): # pragma: no cover
return lambda cls: None
-def make_lineage(self, testfile):
+def make_lineage(config_dir, testfile):
"""Creates a lineage defined by testfile.
This creates the archive, live, and renewal directories if
necessary and creates a simple lineage.
+ :param str config_dir: path to the configuration directory
:param str testfile: configuration file to base the lineage on
:returns: path to the renewal conf file for the created lineage
@@ -121,11 +125,11 @@ def make_lineage(self, testfile):
lineage_name = testfile[:-len('.conf')]
conf_dir = os.path.join(
- self.config_dir, constants.RENEWAL_CONFIGS_DIR)
+ config_dir, constants.RENEWAL_CONFIGS_DIR)
archive_dir = os.path.join(
- self.config_dir, constants.ARCHIVE_DIR, lineage_name)
+ config_dir, constants.ARCHIVE_DIR, lineage_name)
live_dir = os.path.join(
- self.config_dir, constants.LIVE_DIR, lineage_name)
+ config_dir, constants.LIVE_DIR, lineage_name)
for directory in (archive_dir, conf_dir, live_dir,):
if not os.path.exists(directory):
@@ -140,11 +144,11 @@ def make_lineage(self, testfile):
os.symlink(os.path.join(archive_dir, '{0}1.pem'.format(kind)),
os.path.join(live_dir, '{0}.pem'.format(kind)))
- conf_path = os.path.join(self.config_dir, conf_dir, testfile)
+ conf_path = os.path.join(config_dir, conf_dir, testfile)
with open(vector_path(testfile)) as src:
with open(conf_path, 'w') as dst:
dst.writelines(
- line.replace('MAGICDIR', self.config_dir) for line in src)
+ line.replace('MAGICDIR', config_dir) for line in src)
return conf_path
@@ -241,3 +245,47 @@ class TempDirTestCase(unittest.TestCase):
def tearDown(self):
shutil.rmtree(self.tempdir)
+
+
+def lock_and_call(func, lock_path):
+ """Grab a lock for lock_path and call func.
+
+ :param callable func: object to call after acquiring the lock
+ :param str lock_path: path to file or directory to lock
+
+ """
+ # Reload module to reset internal _LOCKS dictionary
+ reload_module(util)
+
+ # start child and wait for it to grab the lock
+ cv = multiprocessing.Condition()
+ cv.acquire()
+ child_args = (cv, lock_path,)
+ child = multiprocessing.Process(target=hold_lock, args=child_args)
+ child.start()
+ cv.wait()
+
+ # call func and terminate the child
+ func()
+ cv.notify()
+ cv.release()
+ child.join()
+ assert child.exitcode == 0
+
+
+def hold_lock(cv, lock_path): # pragma: no cover
+ """Acquire a file lock at lock_path and wait to release it.
+
+ :param multiprocessing.Condition cv: condition for syncronization
+ :param str lock_path: path to the file lock
+
+ """
+ from certbot import lock
+ if os.path.isdir(lock_path):
+ my_lock = lock.lock_dir(lock_path)
+ else:
+ my_lock = lock.LockFile(lock_path)
+ cv.acquire()
+ cv.notify()
+ cv.wait()
+ my_lock.release()
diff --git a/certbot/tests/util_test.py b/certbot/tests/util_test.py
index f021c04cf..3f6bd2a39 100644
--- a/certbot/tests/util_test.py
+++ b/certbot/tests/util_test.py
@@ -2,11 +2,13 @@
import argparse
import errno
import os
+import shutil
import stat
import unittest
import mock
import six
+from six.moves import reload_module # pylint: disable=import-error
from certbot import errors
import certbot.tests.util as test_util
@@ -73,19 +75,52 @@ class ExeExistsTest(unittest.TestCase):
self.assertFalse(self._call("exe"))
-class MakeOrVerifyCoreDirTest(test_util.TempDirTestCase):
+class LockDirUntilExit(test_util.TempDirTestCase):
+ """Tests for certbot.util.lock_dir_until_exit."""
+ @classmethod
+ def _call(cls, *args, **kwargs):
+ from certbot.util import lock_dir_until_exit
+ return lock_dir_until_exit(*args, **kwargs)
+
+ def setUp(self):
+ super(LockDirUntilExit, self).setUp()
+ # reset global state from other tests
+ import certbot.util
+ reload_module(certbot.util)
+
+ @mock.patch('certbot.util.logger')
+ @mock.patch('certbot.util.atexit_register')
+ def test_it(self, mock_register, mock_logger):
+ subdir = os.path.join(self.tempdir, 'subdir')
+ os.mkdir(subdir)
+ self._call(self.tempdir)
+ self._call(subdir)
+ self._call(subdir)
+
+ self.assertEqual(mock_register.call_count, 1)
+ registered_func = mock_register.call_args[0][0]
+ shutil.rmtree(subdir)
+ registered_func() # exception not raised
+ # logger.debug is only called once because the second call
+ # to lock subdir was ignored because it was already locked
+ self.assertEqual(mock_logger.debug.call_count, 1)
+
+
+class SetUpCoreDirTest(test_util.TempDirTestCase):
"""Tests for certbot.util.make_or_verify_core_dir."""
def _call(self, *args, **kwargs):
- from certbot.util import make_or_verify_core_dir
- return make_or_verify_core_dir(*args, **kwargs)
+ from certbot.util import set_up_core_dir
+ return set_up_core_dir(*args, **kwargs)
- def test_success(self):
+ @mock.patch('certbot.util.lock_dir_until_exit')
+ def test_success(self, mock_lock):
new_dir = os.path.join(self.tempdir, 'new')
self._call(new_dir, 0o700, os.geteuid(), False)
self.assertTrue(os.path.exists(new_dir))
+ self.assertEqual(mock_lock.call_count, 1)
- @mock.patch('certbot.main.util.make_or_verify_dir')
+ @mock.patch('certbot.util.make_or_verify_dir')
def test_failure(self, mock_make_or_verify):
mock_make_or_verify.side_effect = OSError
self.assertRaises(errors.Error, self._call,
@@ -333,6 +368,30 @@ class AddDeprecatedArgumentTest(unittest.TestCase):
pass
self.assertTrue("--old-option" not in stdout.getvalue())
+ def test_set_constant(self):
+ """Test when ACTION_TYPES_THAT_DONT_NEED_A_VALUE is a set.
+
+ This variable is a set in configargparse versions < 0.12.0.
+
+ """
+ self._test_constant_common(set)
+
+ def test_tuple_constant(self):
+ """Test when ACTION_TYPES_THAT_DONT_NEED_A_VALUE is a tuple.
+
+ This variable is a tuple in configargparse versions >= 0.12.0.
+
+ """
+ self._test_constant_common(tuple)
+
+ def _test_constant_common(self, typ):
+ with mock.patch("certbot.util.configargparse") as mock_configargparse:
+ mock_configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE = typ()
+ self._call("--old-option", 1)
+ self._call("--old-option2", 2)
+ self.assertEqual(
+ len(mock_configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE), 1)
+
class EnforceLeValidity(unittest.TestCase):
"""Test enforce_le_validity."""
diff --git a/certbot/util.py b/certbot/util.py
index 55a75097f..9db9d7bf9 100644
--- a/certbot/util.py
+++ b/certbot/util.py
@@ -20,6 +20,13 @@ import configargparse
from certbot import constants
from certbot import errors
+from certbot import lock
+
+try:
+ from collections import OrderedDict
+except ImportError: # pragma: no cover
+ # OrderedDict was added in Python 2.7
+ from ordereddict import OrderedDict # pylint: disable=import-error
logger = logging.getLogger(__name__)
@@ -47,6 +54,11 @@ PERM_ERR_FMT = os.linesep.join((
# Stores importing process ID to be used by atexit_register()
_INITIAL_PID = os.getpid()
+# Maps paths to locked directories to their lock object. All locks in
+# the dict are attempted to be cleaned up at program exit. If the
+# program exits before the lock is cleaned up, it is automatically
+# released, but the file isn't deleted.
+_LOCKS = OrderedDict()
def run_script(params, log=logger.error):
@@ -103,20 +115,47 @@ def exe_exists(exe):
return False
-def make_or_verify_core_dir(directory, mode, uid, strict):
- """Make sure directory exists with proper permissions.
+def lock_dir_until_exit(dir_path):
+ """Lock the directory at dir_path until program exit.
+
+ :param str dir_path: path to directory
+
+ :raises errors.LockError: if the lock is held by another process
+
+ """
+ if not _LOCKS: # this is the first lock to be released at exit
+ atexit_register(_release_locks)
+
+ if dir_path not in _LOCKS:
+ _LOCKS[dir_path] = lock.lock_dir(dir_path)
+
+
+def _release_locks():
+ for dir_lock in six.itervalues(_LOCKS):
+ try:
+ dir_lock.release()
+ except: # pylint: disable=bare-except
+ msg = 'Exception occurred releasing lock: {0!r}'.format(dir_lock)
+ logger.debug(msg, exc_info=True)
+
+
+def set_up_core_dir(directory, mode, uid, strict):
+ """Ensure directory exists with proper permissions and is locked.
:param str directory: Path to a directory.
:param int mode: Directory mode.
:param int uid: Directory owner.
:param bool strict: require directory to be owned by current user
+ :raises .errors.LockError: if the directory cannot be locked
:raises .errors.Error: if the directory cannot be made or verified
"""
try:
make_or_verify_dir(directory, mode, uid, strict)
+ lock_dir_until_exit(directory)
except OSError as error:
+ logger.debug("Exception was:", exc_info=True)
raise errors.Error(PERM_ERR_FMT.format(error))
@@ -390,7 +429,8 @@ def get_python_os_info():
elif os_type.startswith('darwin'):
os_ver = subprocess.Popen(
["sw_vers", "-productVersion"],
- stdout=subprocess.PIPE
+ stdout=subprocess.PIPE,
+ universal_newlines=True,
).communicate()[0].rstrip('\n')
elif os_type.startswith('freebsd'):
# eg "9.3-RC3-p1"
@@ -418,6 +458,13 @@ def safe_email(email):
return False
+class _ShowWarning(argparse.Action):
+ """Action to log a warning when an argument is used."""
+ def __call__(self, unused1, unused2, unused3, option_string=None):
+ sys.stderr.write(
+ "Use of {0} is deprecated.\n".format(option_string))
+
+
def add_deprecated_argument(add_argument, argument_name, nargs):
"""Adds a deprecated argument with the name argument_name.
@@ -431,14 +478,17 @@ def add_deprecated_argument(add_argument, argument_name, nargs):
:param nargs: Value for nargs when adding the argument to argparse.
"""
- class ShowWarning(argparse.Action):
- """Action to log a warning when an argument is used."""
- def __call__(self, unused1, unused2, unused3, option_string=None):
- sys.stderr.write(
- "Use of {0} is deprecated.\n".format(option_string))
-
- configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE.add(ShowWarning)
- add_argument(argument_name, action=ShowWarning,
+ if _ShowWarning not in configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE:
+ # In version 0.12.0 ACTION_TYPES_THAT_DONT_NEED_A_VALUE was
+ # changed from a set to a tuple.
+ if isinstance(configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE, set):
+ # pylint: disable=no-member
+ configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE.add(
+ _ShowWarning)
+ else:
+ configargparse.ACTION_TYPES_THAT_DONT_NEED_A_VALUE += (
+ _ShowWarning,)
+ add_argument(argument_name, action=_ShowWarning,
help=argparse.SUPPRESS, nargs=nargs)
diff --git a/docs/api/plugins/dns_common.rst b/docs/api/plugins/dns_common.rst
new file mode 100644
index 000000000..ee3945e74
--- /dev/null
+++ b/docs/api/plugins/dns_common.rst
@@ -0,0 +1,5 @@
+:mod:`certbot.plugins.dns_common`
+---------------------------------
+
+.. automodule:: certbot.plugins.dns_common
+ :members:
diff --git a/docs/api/plugins/dns_common_lexicon.rst b/docs/api/plugins/dns_common_lexicon.rst
new file mode 100644
index 000000000..a48166828
--- /dev/null
+++ b/docs/api/plugins/dns_common_lexicon.rst
@@ -0,0 +1,5 @@
+:mod:`certbot.plugins.dns_common_lexicon`
+-----------------------------------------
+
+.. automodule:: certbot.plugins.dns_common_lexicon
+ :members:
diff --git a/docs/challenges.rst b/docs/challenges.rst
index 0c923c45b..e45b9d852 100644
--- a/docs/challenges.rst
+++ b/docs/challenges.rst
@@ -58,7 +58,7 @@ HTTP-01 challenge:
files in order to have them served by your existing web server.
If you said your webroot for example.com was /var/www/example.com,
then a file placed in /var/www/example.com/.well-known/acme-challenge/testfile should appear on
- your web site at http://example.com/.well-known/acme-challenge/testfile (which you can test using a web browser). (A redirection to HTTPS
+ your web site at `http://example.com/.well-known/acme-challenge/testfile` (which you can test using a web browser). (A redirection to HTTPS
is OK here and should not stop the challenge from working.)
Note that you should *not* specify the .well-known/acme-challenge directory itself. Instead, you should specify the top level directory that web content is served from.
@@ -70,7 +70,7 @@ HTTP-01 challenge:
* (With manual plugin) You updated the webroot directory incorrectly
If you used `--manual`, you need to know where you can put files in order to have them served by your existing web server. If you think your webroot for example.com is /var/www/example.com, then a file placed in /var/www/example.com/.well-known/acme-challenge/testfile should appear on
- your web site at http://example.com/.well-known/acme-challenge/testfile. (A redirection to HTTPS
+ your web site at `http://example.com/.well-known/acme-challenge/testfile`. (A redirection to HTTPS
is OK here and should not stop the challenge from working.) You should also make sure that you don't make a typo in the name of the file when creating it.
* Your existing web server's configuration refuses to serve files
diff --git a/docs/cli-help.txt b/docs/cli-help.txt
index 7a4bf58b9..ef9574a80 100644
--- a/docs/cli-help.txt
+++ b/docs/cli-help.txt
@@ -85,11 +85,16 @@ optional arguments:
--user-agent USER_AGENT
Set a custom user agent string for the client. User
agent strings allow the CA to collect high level
- statistics about success rates by OS and plugin. If
- you wish to hide your server OS version from the Let's
- Encrypt server, set this to "". (default:
- CertbotACMEClient/0.14.0.dev0 (Debian GNU/Linux 9
- (stretch)) Authenticator/XXX Installer/YYY)
+ statistics about success rates by OS, plugin and use
+ case, and to know when to deprecate support for past
+ Python versions and flags. If you wish to hide this
+ information from the Let's Encrypt server, set this to
+ "". (default: CertbotACMEClient/0.14.1 (certbot;
+ Ubuntu 16.04.2 LTS) Authenticator/XXX Installer/YYY
+ (SUBCOMMAND; flags: FLAGS) Py/2.7.12). The flags
+ encoded in the user agent are: --duplicate, --force-
+ renew, --allow-subset-of-names, -n, and whether any
+ hooks are set.
automation:
Arguments for automating execution & other tweaks
@@ -271,8 +276,13 @@ renew:
"/etc/letsencrypt/live/example.com") containing the
new certificates and keys; the shell variable
$RENEWED_DOMAINS will contain a space-delimited list
+<<<<<<< HEAD
of renewed certificate domains (for example,
"example.com www.example.com" (default: None)
+=======
+ of renewed cert domains (for example, "example.com
+ www.example.com" (default: None)
+>>>>>>> master
--disable-hook-validation
Ordinarily the commands specified for --pre-hook
/--post-hook/--renew-hook will be checked for
@@ -372,66 +382,13 @@ plugins:
False)
--nginx Obtain and install certificates using Nginx (default:
False)
- --standalone Obtain certs using a "standalone" webserver. (default:
+ --standalone Obtain certificates using a "standalone" webserver. (default:
False)
--manual Provide laborious manual instructions for obtaining a
certificate (default: False)
--webroot Obtain certificates by placing files in a webroot
directory. (default: False)
-nginx:
- Nginx Web Server plugin - Alpha
-
- --nginx-server-root NGINX_SERVER_ROOT
- Nginx server root directory. (default: /etc/nginx)
- --nginx-ctl NGINX_CTL
- Path to the 'nginx' binary, used for 'configtest' and
- retrieving nginx version number. (default: nginx)
-
-standalone:
- Spin up a temporary webserver
-
-manual:
- Authenticate through manual configuration or custom shell scripts. When
- using shell scripts, an authenticator script must be provided. The
- environment variables available to this script are $CERTBOT_DOMAIN which
- contains the domain being authenticated, $CERTBOT_VALIDATION which is the
- validation string, and $CERTBOT_TOKEN which is the filename of the
- resource requested when performing an HTTP-01 challenge. An additional
- cleanup script can also be provided and can use the additional variable
- $CERTBOT_AUTH_OUTPUT which contains the stdout output from the auth
- script.
-
- --manual-auth-hook MANUAL_AUTH_HOOK
- Path or command to execute for the authentication
- script (default: None)
- --manual-cleanup-hook MANUAL_CLEANUP_HOOK
- Path or command to execute for the cleanup script
- (default: None)
- --manual-public-ip-logging-ok
- Automatically allows public IP logging (default: Ask)
-
-webroot:
- Place files in webroot directory
-
- --webroot-path WEBROOT_PATH, -w WEBROOT_PATH
- public_html / webroot path. This can be specified
- multiple times to handle different domains; each
- domain will have the webroot path that preceded it.
- For instance: `-w /var/www/example -d example.com -d
- www.example.com -w /var/www/thing -d thing.net -d
- m.thing.net` (default: Ask)
- --webroot-map WEBROOT_MAP
- JSON dictionary mapping domains to webroot paths; this
- implies -d for each entry. You may need to escape this
- from your shell. E.g.: --webroot-map
- '{"eg1.is,m.eg1.is":"/www/eg1/", "eg2.is":"/www/eg2"}'
- This option is merged with, but takes precedence over,
- -w / -d entries. At present, if you put webroot-map in
- a config file, it needs to be on a single line, like:
- webroot-map = {"example.com":"/var/www"}. (default:
- {})
-
apache:
Apache Web Server plugin - Beta
@@ -462,5 +419,58 @@ apache:
Let installer handle enabling sites for you.(Only
Ubuntu/Debian currently) (default: True)
+manual:
+ Authenticate through manual configuration or custom shell scripts. When
+ using shell scripts, an authenticator script must be provided. The
+ environment variables available to this script are $CERTBOT_DOMAIN which
+ contains the domain being authenticated, $CERTBOT_VALIDATION which is the
+ validation string, and $CERTBOT_TOKEN which is the filename of the
+ resource requested when performing an HTTP-01 challenge. An additional
+ cleanup script can also be provided and can use the additional variable
+ $CERTBOT_AUTH_OUTPUT which contains the stdout output from the auth
+ script.
+
+ --manual-auth-hook MANUAL_AUTH_HOOK
+ Path or command to execute for the authentication
+ script (default: None)
+ --manual-cleanup-hook MANUAL_CLEANUP_HOOK
+ Path or command to execute for the cleanup script
+ (default: None)
+ --manual-public-ip-logging-ok
+ Automatically allows public IP logging (default: Ask)
+
+nginx:
+ Nginx Web Server plugin - Alpha
+
+ --nginx-server-root NGINX_SERVER_ROOT
+ Nginx server root directory. (default: /etc/nginx)
+ --nginx-ctl NGINX_CTL
+ Path to the 'nginx' binary, used for 'configtest' and
+ retrieving nginx version number. (default: nginx)
+
null:
Null Installer
+
+standalone:
+ Spin up a temporary webserver
+
+webroot:
+ Place files in webroot directory
+
+ --webroot-path WEBROOT_PATH, -w WEBROOT_PATH
+ public_html / webroot path. This can be specified
+ multiple times to handle different domains; each
+ domain will have the webroot path that preceded it.
+ For instance: `-w /var/www/example -d example.com -d
+ www.example.com -w /var/www/thing -d thing.net -d
+ m.thing.net` (default: Ask)
+ --webroot-map WEBROOT_MAP
+ JSON dictionary mapping domains to webroot paths; this
+ implies -d for each entry. You may need to escape this
+ from your shell. E.g.: --webroot-map
+ '{"eg1.is,m.eg1.is":"/www/eg1/", "eg2.is":"/www/eg2"}'
+ This option is merged with, but takes precedence over,
+ -w / -d entries. At present, if you put webroot-map in
+ a config file, it needs to be on a single line, like:
+ webroot-map = {"example.com":"/var/www"}. (default:
+ {})
diff --git a/docs/install.rst b/docs/install.rst
index 6c56584be..a1e91c010 100644
--- a/docs/install.rst
+++ b/docs/install.rst
@@ -22,8 +22,8 @@ your system.
System Requirements
===================
-Certbot currently requires Python 2.6 or 2.7. By default, it requires root
-access in order to write to ``/etc/letsencrypt``,
+Certbot currently requires Python 2.6, 2.7, or 3.3+. By default, it requires
+root access in order to write to ``/etc/letsencrypt``,
``/var/log/letsencrypt``, ``/var/lib/letsencrypt``; to bind to ports 80 and 443
(if you use the ``standalone`` plugin) and to read and modify webserver
configurations (if you use the ``apache`` or ``nginx`` plugins). If none of
diff --git a/docs/using.rst b/docs/using.rst
index 614f79608..4ef0f5414 100644
--- a/docs/using.rst
+++ b/docs/using.rst
@@ -430,7 +430,7 @@ apply appropriate file permissions.
esac
done
- More information about renewal hooks can be found by running
+More information about renewal hooks can be found by running
``certbot --help renew``.
If you're sure that this command executes successfully without human
diff --git a/letsencrypt-auto b/letsencrypt-auto
index fc8007c9e..39edbb3c5 100755
--- a/letsencrypt-auto
+++ b/letsencrypt-auto
@@ -15,6 +15,11 @@ set -e # Work even if somebody does "sh thisscript.sh".
# 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
+
+# HOME might not be defined when being run through something like systemd
+if [ -z "$HOME" ]; then
+ HOME=~root
+fi
if [ -z "$XDG_DATA_HOME" ]; then
XDG_DATA_HOME=~/.local/share
fi
@@ -23,7 +28,7 @@ if [ -z "$VENV_PATH" ]; then
VENV_PATH="$XDG_DATA_HOME/$VENV_NAME"
fi
VENV_BIN="$VENV_PATH/bin"
-LE_AUTO_VERSION="0.13.0"
+LE_AUTO_VERSION="0.14.1"
BASENAME=$(basename $0)
USAGE="Usage: $BASENAME [OPTIONS]
A self-updating wrapper script for the Certbot ACME client. When run, updates
@@ -59,7 +64,7 @@ for arg in "$@" ; do
NO_BOOTSTRAP=1;;
--help)
HELP=1;;
- --noninteractive|--non-interactive)
+ --noninteractive|--non-interactive|renew)
ASSUME_YES=1;;
--quiet)
QUIET=1;;
@@ -93,6 +98,16 @@ if [ "$QUIET" = 1 ]; then
ASSUME_YES=1
fi
+say() {
+ if [ "$QUIET" != 1 ]; then
+ echo "$@"
+ fi
+}
+
+error() {
+ echo "$@"
+}
+
# Support for busybox and others where there is no "command",
# but "which" instead
if command -v command > /dev/null 2>&1 ; then
@@ -100,7 +115,7 @@ if command -v command > /dev/null 2>&1 ; then
elif which which > /dev/null 2>&1 ; then
export EXISTS="which"
else
- echo "Cannot find command nor which... please install one!"
+ error "Cannot find command nor which... please install one!"
exit 1
fi
@@ -145,17 +160,17 @@ if [ -n "${LE_AUTO_SUDO+x}" ]; then
;;
'') ;; # Nothing to do for plain root method.
*)
- echo "Error: unknown root authorization mechanism '$LE_AUTO_SUDO'."
+ error "Error: unknown root authorization mechanism '$LE_AUTO_SUDO'."
exit 1
esac
- echo "Using preset root authorization mechanism '$LE_AUTO_SUDO'."
+ say "Using preset root authorization mechanism '$LE_AUTO_SUDO'."
else
if test "`id -u`" -ne "0" ; then
if $EXISTS sudo 1>/dev/null 2>&1; then
SUDO=sudo
SUDO_ENV="CERTBOT_AUTO=$0"
else
- echo \"sudo\" is not available, will use \"su\" for installation steps...
+ say \"sudo\" is not available, will use \"su\" for installation steps...
SUDO=su_sudo
fi
else
@@ -165,7 +180,7 @@ fi
BootstrapMessage() {
# Arguments: Platform name
- echo "Bootstrapping dependencies for $1... (you can skip this with --no-bootstrap)"
+ say "Bootstrapping dependencies for $1... (you can skip this with --no-bootstrap)"
}
ExperimentalBootstrap() {
@@ -176,11 +191,11 @@ ExperimentalBootstrap() {
$2
fi
else
- echo "FATAL: $1 support is very experimental at present..."
- echo "if you would like to work on improving it, please ensure you have backups"
- echo "and then run this script again with the --debug flag!"
- echo "Alternatively, you can install OS dependencies yourself and run this script"
- echo "again with --no-bootstrap."
+ error "FATAL: $1 support is very experimental at present..."
+ error "if you would like to work on improving it, please ensure you have backups"
+ error "and then run this script again with the --debug flag!"
+ error "Alternatively, you can install OS dependencies yourself and run this script"
+ error "again with --no-bootstrap."
exit 1
fi
}
@@ -191,15 +206,15 @@ DeterminePythonVersion() {
$EXISTS "$LE_PYTHON" > /dev/null && break
done
if [ "$?" != "0" ]; then
- echo "Cannot find any Pythons; please install one!"
+ error "Cannot find any Pythons; please install one!"
exit 1
fi
export LE_PYTHON
PYVER=`"$LE_PYTHON" -V 2>&1 | cut -d" " -f 2 | cut -d. -f1,2 | sed 's/\.//'`
if [ "$PYVER" -lt 26 ]; then
- echo "You have an ancient version of Python entombed in your operating system..."
- echo "This isn't going to work; you'll need at least version 2.6."
+ error "You have an ancient version of Python entombed in your operating system..."
+ error "This isn't going to work; you'll need at least version 2.6."
exit 1
fi
}
@@ -227,7 +242,7 @@ BootstrapDebCommon() {
QUIET_FLAG='-qq'
fi
- $SUDO apt-get $QUIET_FLAG update || echo apt-get update hit problems but continuing anyway...
+ $SUDO apt-get $QUIET_FLAG update || error apt-get update hit problems but continuing anyway...
# virtualenv binary can be found in different packages depending on
# distro version (#346)
@@ -255,7 +270,7 @@ BootstrapDebCommon() {
# ARGS:
BACKPORT_NAME="$1"
BACKPORT_SOURCELINE="$2"
- echo "To use the Apache Certbot plugin, augeas needs to be installed from $BACKPORT_NAME."
+ say "To use the Apache Certbot plugin, augeas needs to be installed from $BACKPORT_NAME."
if ! grep -v -e ' *#' /etc/apt/sources.list | grep -q "$BACKPORT_NAME" ; then
# This can theoretically error if sources.list.d is empty, but in that case we don't care.
if ! grep -v -e ' *#' /etc/apt/sources.list.d/* 2>/dev/null | grep -q "$BACKPORT_NAME"; then
@@ -315,7 +330,7 @@ BootstrapDebCommon() {
if ! $EXISTS virtualenv > /dev/null ; then
- echo Failed to install a working \"virtualenv\" command, exiting
+ error Failed to install a working \"virtualenv\" command, exiting
exit 1
fi
}
@@ -335,7 +350,7 @@ BootstrapRpmCommon() {
tool=yum
else
- echo "Neither yum nor dnf found. Aborting bootstrap!"
+ error "Neither yum nor dnf found. Aborting bootstrap!"
exit 1
fi
@@ -349,7 +364,7 @@ BootstrapRpmCommon() {
if ! $SUDO $tool list *virtualenv >/dev/null 2>&1; then
echo "To use Certbot, packages from the EPEL repository need to be installed."
if ! $SUDO $tool list epel-release >/dev/null 2>&1; then
- echo "Please enable this repository and try running Certbot again."
+ error "Enable the EPEL repository and try running Certbot again."
exit 1
fi
if [ "$ASSUME_YES" = 1 ]; then
@@ -361,7 +376,7 @@ BootstrapRpmCommon() {
sleep 1s
fi
if ! $SUDO $tool install $yes_flag $QUIET_FLAG epel-release; then
- echo "Could not enable EPEL. Aborting bootstrap!"
+ error "Could not enable EPEL. Aborting bootstrap!"
exit 1
fi
fi
@@ -403,7 +418,7 @@ BootstrapRpmCommon() {
fi
if ! $SUDO $tool install $yes_flag $QUIET_FLAG $pkgs; then
- echo "Could not install OS dependencies. Aborting bootstrap!"
+ error "Could not install OS dependencies. Aborting bootstrap!"
exit 1
fi
}
@@ -508,15 +523,15 @@ BootstrapFreeBsd() {
BootstrapMac() {
if hash brew 2>/dev/null; then
- echo "Using Homebrew to install dependencies..."
+ say "Using Homebrew to install dependencies..."
pkgman=brew
pkgcmd="brew install"
elif hash port 2>/dev/null; then
- echo "Using MacPorts to install dependencies..."
+ say "Using MacPorts to install dependencies..."
pkgman=port
pkgcmd="$SUDO port install"
else
- echo "No Homebrew/MacPorts; installing Homebrew..."
+ say "No Homebrew/MacPorts; installing Homebrew..."
ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"
pkgman=brew
pkgcmd="brew install"
@@ -527,26 +542,26 @@ BootstrapMac() {
-o "$(which python)" = "/usr/bin/python" ]; then
# We want to avoid using the system Python because it requires root to use pip.
# python.org, MacPorts or HomeBrew Python installations should all be OK.
- echo "Installing python..."
+ say "Installing python..."
$pkgcmd python
fi
# Workaround for _dlopen not finding augeas on macOS
if [ "$pkgman" = "port" ] && ! [ -e "/usr/local/lib/libaugeas.dylib" ] && [ -e "/opt/local/lib/libaugeas.dylib" ]; then
- echo "Applying augeas workaround"
+ say "Applying augeas workaround"
$SUDO mkdir -p /usr/local/lib/
$SUDO ln -s /opt/local/lib/libaugeas.dylib /usr/local/lib/
fi
if ! hash pip 2>/dev/null; then
- echo "pip not installed"
- echo "Installing pip..."
+ say "pip not installed"
+ say "Installing pip..."
curl --silent --show-error --retry 5 https://bootstrap.pypa.io/get-pip.py | python
fi
if ! hash virtualenv 2>/dev/null; then
- echo "virtualenv not installed."
- echo "Installing with pip..."
+ say "virtualenv not installed."
+ say "Installing with pip..."
pip install virtualenv
fi
}
@@ -566,7 +581,7 @@ BootstrapMageiaCommon() {
libpython-devel \
python-virtualenv
then
- echo "Could not install Python dependencies. Aborting bootstrap!"
+ error "Could not install Python dependencies. Aborting bootstrap!"
exit 1
fi
@@ -578,7 +593,7 @@ BootstrapMageiaCommon() {
libffi-devel \
rootcerts
then
- echo "Could not install additional dependencies. Aborting bootstrap!"
+ error "Could not install additional dependencies. Aborting bootstrap!"
exit 1
fi
}
@@ -605,11 +620,11 @@ Bootstrap() {
BootstrapMessage "Archlinux"
BootstrapArchCommon
else
- echo "Please use pacman to install letsencrypt packages:"
- echo "# pacman -S certbot certbot-apache"
- echo
- echo "If you would like to use the virtualenv way, please run the script again with the"
- echo "--debug flag."
+ error "Please use pacman to install letsencrypt packages:"
+ error "# pacman -S certbot certbot-apache"
+ error
+ error "If you would like to use the virtualenv way, please run the script again with the"
+ error "--debug flag."
exit 1
fi
elif [ -f /etc/manjaro-release ]; then
@@ -625,11 +640,11 @@ Bootstrap() {
elif [ -f /etc/product ] && grep -q "Joyent Instance" /etc/product ; then
ExperimentalBootstrap "Joyent SmartOS Zone" BootstrapSmartOS
else
- echo "Sorry, I don't know how to bootstrap Certbot on your operating system!"
- echo
- echo "You will need to install OS dependencies, configure virtualenv, and run pip install manually."
- echo "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites"
- echo "for more info."
+ error "Sorry, I don't know how to bootstrap Certbot on your operating system!"
+ error
+ error "You will need to install OS dependencies, configure virtualenv, and run pip install manually."
+ error "Please see https://letsencrypt.readthedocs.org/en/latest/contributing.html#prerequisites"
+ error "for more info."
exit 1
fi
}
@@ -649,7 +664,7 @@ if [ "$1" = "--le-auto-phase2" ]; then
# grep for both certbot and letsencrypt until certbot and shim packages have been released
INSTALLED_VERSION=$("$VENV_BIN/letsencrypt" --version 2>&1 | grep "^certbot\|^letsencrypt" | cut -d " " -f 2)
if [ -z "$INSTALLED_VERSION" ]; then
- echo "Error: couldn't get currently installed version for $VENV_BIN/letsencrypt: " 1>&2
+ error "Error: couldn't get currently installed version for $VENV_BIN/letsencrypt: " 1>&2
"$VENV_BIN/letsencrypt" --version
exit 1
fi
@@ -657,7 +672,7 @@ if [ "$1" = "--le-auto-phase2" ]; then
INSTALLED_VERSION="none"
fi
if [ "$LE_AUTO_VERSION" != "$INSTALLED_VERSION" ]; then
- echo "Creating virtual environment..."
+ say "Creating virtual environment..."
DeterminePythonVersion
rm -rf "$VENV_PATH"
if [ "$VERBOSE" = 1 ]; then
@@ -666,7 +681,7 @@ if [ "$1" = "--le-auto-phase2" ]; then
virtualenv --no-site-packages --python "$LE_PYTHON" "$VENV_PATH" > /dev/null
fi
- echo "Installing Python packages..."
+ say "Installing Python packages..."
TEMP_DIR=$(TempDir)
trap 'rm -rf "$TEMP_DIR"' EXIT
# There is no $ interpolation due to quotes on starting heredoc delimiter.
@@ -845,18 +860,18 @@ letsencrypt==0.7.0 \
# THE LINES BELOW ARE EDITED BY THE RELEASE SCRIPT; ADD ALL DEPENDENCIES ABOVE.
-acme==0.13.0 \
- --hash=sha256:103ce8bed43aad1a9655ed815df09bbeab86ee16cc82137b44d9dac68faa394f \
- --hash=sha256:7489b3e20d02da0a389aedb82408ffb6b76294e41d833db85591b9f779539815
-certbot==0.13.0 \
- --hash=sha256:65d0d9d158972aff7746d4ef80a20465a14c54ae8bcb879216970c2a1b34503c \
- --hash=sha256:f63ad7747edaca2fb7d60c28882e44d2f48ff1cca9b9c7c251ad47e2189c00f3
-certbot-apache==0.13.0 \
- --hash=sha256:22f7c1dc93439384c0874960081d66957910c6dc737a9facbd9fcbc46c545874 \
- --hash=sha256:b43b04b53005e7218a09a0ba4d97581fab369e929472fa49fb55d29d0ab54589
-certbot-nginx==0.13.0 \
- --hash=sha256:9d0ab4eeb98b0ebad70ba116b32268342ad343d82d64990a652ff8072959b044 \
- --hash=sha256:f026a8faee8397a22c5d4a7623a6ef7c7e780ed63a3bdf9940f43f7823aa2a72
+acme==0.14.1 \
+ --hash=sha256:f535d6459dcafa436749a8d2fdfafed21b792efa05b8bd3263fcd739c2e1497c \
+ --hash=sha256:0e6d9d1bbb71d80c61c8d10ab9a40bcf38e25f0fa016b9769e96ebf5a79b552b
+certbot==0.14.1 \
+ --hash=sha256:f950a058d4f657160de4ad163d9f781fe7adeec0c0a44556841adb03ad135d13 \
+ --hash=sha256:519b28124869d97116cb1f2f04ccc2937c0b2fd32fce43576eb80c0e4ff1ab65
+certbot-apache==0.14.1 \
+ --hash=sha256:1dda9b4dcf66f6dfba37c787d849e69ad25a344572f74a76fc4447bb1a5417b2 \
+ --hash=sha256:da84996e345fc5789da3575225536b27fa3b35f89b2db2d8f494a34bced14f9b
+certbot-nginx==0.14.1 \
+ --hash=sha256:bd3d4a1dcd6fa9e8ead19a9da88693f08b63464c86be2442e42cd60565c3f05f \
+ --hash=sha256:f0c19f667072e4cfa6b92abf8312b6bee3ed1d2432676b211593034e7d1abb7e
UNLIKELY_EOF
# -------------------------------------------------------------------------
@@ -1022,42 +1037,40 @@ UNLIKELY_EOF
set -e
if [ "$PIP_STATUS" != 0 ]; then
# Report error. (Otherwise, be quiet.)
- echo "Had a problem while installing Python packages."
+ error "Had a problem while installing Python packages."
if [ "$VERBOSE" != 1 ]; then
- echo
- echo "pip prints the following errors: "
- echo "====================================================="
- echo "$PIP_OUT"
- echo "====================================================="
- echo
- echo "Certbot has problem setting up the virtual environment."
+ error
+ error "pip prints the following errors: "
+ error "====================================================="
+ error "$PIP_OUT"
+ error "====================================================="
+ error
+ error "Certbot has problem setting up the virtual environment."
if `echo $PIP_OUT | grep -q Killed` || `echo $PIP_OUT | grep -q "allocate memory"` ; then
- echo
- echo "Based on your pip output, the problem can likely be fixed by "
- echo "increasing the available memory."
+ error
+ error "Based on your pip output, the problem can likely be fixed by "
+ error "increasing the available memory."
else
- echo
- echo "We were not be able to guess the right solution from your pip "
- echo "output."
+ error
+ error "We were not be able to guess the right solution from your pip "
+ error "output."
fi
- echo
- echo "Consult https://certbot.eff.org/docs/install.html#problems-with-python-virtual-environment"
- echo "for possible solutions."
- echo "You may also find some support resources at https://certbot.eff.org/support/ ."
+ error
+ error "Consult https://certbot.eff.org/docs/install.html#problems-with-python-virtual-environment"
+ error "for possible solutions."
+ error "You may also find some support resources at https://certbot.eff.org/support/ ."
fi
rm -rf "$VENV_PATH"
exit 1
fi
- echo "Installation succeeded."
+ say "Installation succeeded."
fi
if [ -n "$SUDO" ]; then
# SUDO is su wrapper or sudo
- if [ "$QUIET" != 1 ]; then
- echo "Requesting root privileges to run certbot..."
- echo " $VENV_BIN/letsencrypt" "$@"
- fi
+ say "Requesting root privileges to run certbot..."
+ say " $VENV_BIN/letsencrypt" "$@"
fi
if [ -z "$SUDO_ENV" ] ; then
# SUDO is su wrapper / noop
@@ -1084,7 +1097,7 @@ else
Bootstrap
fi
if [ "$OS_PACKAGES_ONLY" = 1 ]; then
- echo "OS packages installed."
+ say "OS packages installed."
exit 0
fi
@@ -1227,9 +1240,9 @@ UNLIKELY_EOF
# ---------------------------------------------------------------------------
DeterminePythonVersion
if ! REMOTE_VERSION=`"$LE_PYTHON" "$TEMP_DIR/fetch.py" --latest-version` ; then
- echo "WARNING: unable to check for updates."
+ error "WARNING: unable to check for updates."
elif [ "$LE_AUTO_VERSION" != "$REMOTE_VERSION" ]; then
- echo "Upgrading certbot-auto $LE_AUTO_VERSION to $REMOTE_VERSION..."
+ say "Upgrading certbot-auto $LE_AUTO_VERSION to $REMOTE_VERSION..."
# Now we drop into Python so we don't have to install even more
# dependencies (curl, etc.), for better flow control, and for the option of
@@ -1238,7 +1251,7 @@ UNLIKELY_EOF
# Install new copy of certbot-auto.
# TODO: Deal with quotes in pathnames.
- echo "Replacing certbot-auto..."
+ say "Replacing certbot-auto..."
# Clone permissions with cp. chmod and chown don't have a --reference
# option on macOS or BSD, and stat -c on Linux is stat -f on macOS and BSD:
$SUDO cp -p "$0" "$TEMP_DIR/letsencrypt-auto.permission-clone"
diff --git a/letsencrypt-auto-source/build.py b/letsencrypt-auto-source/build.py
index eebad61b7..a1e40fe44 100755
--- a/letsencrypt-auto-source/build.py
+++ b/letsencrypt-auto-source/build.py
@@ -21,14 +21,17 @@ def build(version=None, requirements=None):
:arg version: The version to attach to the script. Default: the version of
the certbot package
:arg requirements: The contents of the requirements file to embed. Default:
- contents of letsencrypt-auto-requirements.txt
+ contents of dependency-requirements.txt, letsencrypt-requirements.txt,
+ and certbot-requirements.txt
"""
special_replacements = {
'LE_AUTO_VERSION': version or certbot_version(DIR)
}
if requirements:
- special_replacements['letsencrypt-auto-requirements.txt'] = requirements
+ special_replacements['dependency-requirements.txt'] = ''
+ special_replacements['letsencrypt-requirements.txt'] = ''
+ special_replacements['certbot-requirements.txt'] = requirements
def replacer(match):
token = match.group(1)
diff --git a/letsencrypt-auto-source/certbot-auto.asc b/letsencrypt-auto-source/certbot-auto.asc
index bf267dc25..cdc9ef58e 100644
--- a/letsencrypt-auto-source/certbot-auto.asc
+++ b/letsencrypt-auto-source/certbot-auto.asc
@@ -1,11 +1,11 @@
-----BEGIN PGP SIGNATURE-----
Version: GnuPG v2
-iQEcBAABCAAGBQJY5WxEAAoJEE0XyZXNl3XyoDYH/joyJ/7cS4+SoTEiPpVcDnK+
-YJVhxP6pir6GaRvl+ebWlo7ichS4c0Kye8e5BPVj5RtZbDT88iplMZ2EyUmeA579
-8Z96p9qoEANeGWiPe+KCDXRHJfCAsphcHSLTeS8lXgG8SP13p7hsML6hn3gosRdu
-OG4/SnFBDLLwu4YwUVom4U+Z+dYS1jQstge4sexr85jCX/Lds7M5WM/lFiYMBsJ8
-uZd/IGKwb7jvsc4u58Ruj9xiTcchaxn15NMJR7R967Mt5ortSvZ3C6Cv3NyubJmB
-hmGQVU+eNBTeEwPSIN8xAf3fcwh2wlRMaTZOy5nJ3IoDdSQuwO9IGxxdkNDSegE=
-=8KUq
+iQEcBAABCAAGBQJZGzDgAAoJEE0XyZXNl3XyBXYIAIYBMJKzAbLYsHrP/KF3aLLh
+S9AWK5IP/tftHWgxS0mQ0JqQvWsRLGoQo7xaeKKIBD8QQsHA9hsdxPwy++rQcaZY
+AzvpUBPIfiCDCa1XPiRy7YduAvsAoPB7jncP8rYdoFZL3lcUpbmI/9Sk1nlsm81n
+5EcNJ9T8RRAkkH0i6DTLine48DgI7MlLhce/mAr3wDrcKAmENZksZW7vgAlI69ri
+cTb+qIlwgFRLAF0Q41klTiFdHi6+vj+mFHHNFyuERpf7VT3ngBZmAmiRybxo/m8g
+p9/54LGw3bQ25uAZXKVtIX5CqOoJL1GHe13MEyDOgBSDp+KqNGWJ8PEPA9XGwqw=
+=H8UX
-----END PGP SIGNATURE-----
diff --git a/letsencrypt-auto-source/letsencrypt-auto b/letsencrypt-auto-source/letsencrypt-auto
index 50c80f0a4..983c7e33d 100755
--- a/letsencrypt-auto-source/letsencrypt-auto
+++ b/letsencrypt-auto-source/letsencrypt-auto
@@ -28,7 +28,7 @@ if [ -z "$VENV_PATH" ]; then
VENV_PATH="$XDG_DATA_HOME/$VENV_NAME"
fi
VENV_BIN="$VENV_PATH/bin"
-LE_AUTO_VERSION="0.14.0.dev0"
+LE_AUTO_VERSION="0.15.0.dev0"
BASENAME=$(basename $0)
USAGE="Usage: $BASENAME [OPTIONS]
A self-updating wrapper script for the Certbot ACME client. When run, updates
@@ -694,7 +694,7 @@ if [ "$1" = "--le-auto-phase2" ]; then
# Hashin example:
# pip install hashin
-# hashin -r letsencrypt-auto-requirements.txt cryptography==1.5.2
+# hashin -r dependency-requirements.txt cryptography==1.5.2
# sets the new certbot-auto pinned version of cryptography to 1.5.2
argparse==1.4.0 \
@@ -754,9 +754,9 @@ cryptography==1.5.3 \
enum34==1.1.2 \
--hash=sha256:2475d7fcddf5951e92ff546972758802de5260bf409319a9f1934e6bbc8b1dc7 \
--hash=sha256:35907defb0f992b75ab7788f65fedc1cf20ffa22688e0e6f6f12afc06b3ea501
-funcsigs==0.4 \
- --hash=sha256:ff5ad9e2f8d9e5d1e8bbfbcf47722ab527cf0d51caeeed9da6d0f40799383fde \
- --hash=sha256:d83ce6df0b0ea6618700fe1db353526391a8a3ada1b7aba52fed7a61da772033
+funcsigs==1.0.2 \
+ --hash=sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca \
+ --hash=sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50
idna==2.0 \
--hash=sha256:9b2fc50bd3c4ba306b9651b69411ef22026d4d8335b93afc2214cef1246ce707 \
--hash=sha256:16199aad938b290f5be1057c0e1efc6546229391c23cea61ca940c115f7d3d3b
@@ -851,27 +851,33 @@ zope.interface==4.1.3 \
--hash=sha256:928138365245a0e8869a5999fbcc2a45475a0a6ed52a494d60dbdc540335fedd \
--hash=sha256:0d841ba1bb840eea0e6489dc5ecafa6125554971f53b5acb87764441e61bceba \
--hash=sha256:b09c8c1d47b3531c400e0195697f1414a63221de6ef478598a4f1460f7d9a392
-mock==1.0.1 \
- --hash=sha256:b839dd2d9c117c701430c149956918a423a9863b48b09c90e30a6013e7d2f44f \
- --hash=sha256:8f83080daa249d036cbccfb8ae5cc6ff007b88d6d937521371afabe7b19badbc
+mock==2.0.0 \
+ --hash=sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1 \
+ --hash=sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba
+
+# Contains the requirements for the letsencrypt package.
+#
+# Since the letsencrypt package depends on certbot and using pip with hashes
+# requires that all installed packages have hashes listed, this allows
+# dependency-requirements.txt to be used without requiring a hash for a
+# (potentially unreleased) Certbot package.
+
letsencrypt==0.7.0 \
--hash=sha256:105a5fb107e45bcd0722eb89696986dcf5f08a86a321d6aef25a0c7c63375ade \
--hash=sha256:c36e532c486a7e92155ee09da54b436a3c420813ec1c590b98f635d924720de9
-# THE LINES BELOW ARE EDITED BY THE RELEASE SCRIPT; ADD ALL DEPENDENCIES ABOVE.
-
-acme==0.13.0 \
- --hash=sha256:103ce8bed43aad1a9655ed815df09bbeab86ee16cc82137b44d9dac68faa394f \
- --hash=sha256:7489b3e20d02da0a389aedb82408ffb6b76294e41d833db85591b9f779539815
-certbot==0.13.0 \
- --hash=sha256:65d0d9d158972aff7746d4ef80a20465a14c54ae8bcb879216970c2a1b34503c \
- --hash=sha256:f63ad7747edaca2fb7d60c28882e44d2f48ff1cca9b9c7c251ad47e2189c00f3
-certbot-apache==0.13.0 \
- --hash=sha256:22f7c1dc93439384c0874960081d66957910c6dc737a9facbd9fcbc46c545874 \
- --hash=sha256:b43b04b53005e7218a09a0ba4d97581fab369e929472fa49fb55d29d0ab54589
-certbot-nginx==0.13.0 \
- --hash=sha256:9d0ab4eeb98b0ebad70ba116b32268342ad343d82d64990a652ff8072959b044 \
- --hash=sha256:f026a8faee8397a22c5d4a7623a6ef7c7e780ed63a3bdf9940f43f7823aa2a72
+acme==0.14.1 \
+ --hash=sha256:f535d6459dcafa436749a8d2fdfafed21b792efa05b8bd3263fcd739c2e1497c \
+ --hash=sha256:0e6d9d1bbb71d80c61c8d10ab9a40bcf38e25f0fa016b9769e96ebf5a79b552b
+certbot==0.14.1 \
+ --hash=sha256:f950a058d4f657160de4ad163d9f781fe7adeec0c0a44556841adb03ad135d13 \
+ --hash=sha256:519b28124869d97116cb1f2f04ccc2937c0b2fd32fce43576eb80c0e4ff1ab65
+certbot-apache==0.14.1 \
+ --hash=sha256:1dda9b4dcf66f6dfba37c787d849e69ad25a344572f74a76fc4447bb1a5417b2 \
+ --hash=sha256:da84996e345fc5789da3575225536b27fa3b35f89b2db2d8f494a34bced14f9b
+certbot-nginx==0.14.1 \
+ --hash=sha256:bd3d4a1dcd6fa9e8ead19a9da88693f08b63464c86be2442e42cd60565c3f05f \
+ --hash=sha256:f0c19f667072e4cfa6b92abf8312b6bee3ed1d2432676b211593034e7d1abb7e
UNLIKELY_EOF
# -------------------------------------------------------------------------
diff --git a/letsencrypt-auto-source/letsencrypt-auto.sig b/letsencrypt-auto-source/letsencrypt-auto.sig
index 723cc2f8c..9a95dea57 100644
Binary files a/letsencrypt-auto-source/letsencrypt-auto.sig and b/letsencrypt-auto-source/letsencrypt-auto.sig differ
diff --git a/letsencrypt-auto-source/letsencrypt-auto.template b/letsencrypt-auto-source/letsencrypt-auto.template
index f6585a378..305435a9b 100755
--- a/letsencrypt-auto-source/letsencrypt-auto.template
+++ b/letsencrypt-auto-source/letsencrypt-auto.template
@@ -317,7 +317,9 @@ if [ "$1" = "--le-auto-phase2" ]; then
# There is no $ interpolation due to quotes on starting heredoc delimiter.
# -------------------------------------------------------------------------
cat << "UNLIKELY_EOF" > "$TEMP_DIR/letsencrypt-auto-requirements.txt"
-{{ letsencrypt-auto-requirements.txt }}
+{{ dependency-requirements.txt }}
+{{ letsencrypt-requirements.txt }}
+{{ certbot-requirements.txt }}
UNLIKELY_EOF
# -------------------------------------------------------------------------
cat << "UNLIKELY_EOF" > "$TEMP_DIR/pipstrap.py"
diff --git a/letsencrypt-auto-source/pieces/certbot-requirements.txt b/letsencrypt-auto-source/pieces/certbot-requirements.txt
new file mode 100644
index 000000000..90b47a3cb
--- /dev/null
+++ b/letsencrypt-auto-source/pieces/certbot-requirements.txt
@@ -0,0 +1,12 @@
+acme==0.14.1 \
+ --hash=sha256:f535d6459dcafa436749a8d2fdfafed21b792efa05b8bd3263fcd739c2e1497c \
+ --hash=sha256:0e6d9d1bbb71d80c61c8d10ab9a40bcf38e25f0fa016b9769e96ebf5a79b552b
+certbot==0.14.1 \
+ --hash=sha256:f950a058d4f657160de4ad163d9f781fe7adeec0c0a44556841adb03ad135d13 \
+ --hash=sha256:519b28124869d97116cb1f2f04ccc2937c0b2fd32fce43576eb80c0e4ff1ab65
+certbot-apache==0.14.1 \
+ --hash=sha256:1dda9b4dcf66f6dfba37c787d849e69ad25a344572f74a76fc4447bb1a5417b2 \
+ --hash=sha256:da84996e345fc5789da3575225536b27fa3b35f89b2db2d8f494a34bced14f9b
+certbot-nginx==0.14.1 \
+ --hash=sha256:bd3d4a1dcd6fa9e8ead19a9da88693f08b63464c86be2442e42cd60565c3f05f \
+ --hash=sha256:f0c19f667072e4cfa6b92abf8312b6bee3ed1d2432676b211593034e7d1abb7e
diff --git a/letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt b/letsencrypt-auto-source/pieces/dependency-requirements.txt
similarity index 88%
rename from letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt
rename to letsencrypt-auto-source/pieces/dependency-requirements.txt
index 325bdf84f..4cba83195 100644
--- a/letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt
+++ b/letsencrypt-auto-source/pieces/dependency-requirements.txt
@@ -5,7 +5,7 @@
# Hashin example:
# pip install hashin
-# hashin -r letsencrypt-auto-requirements.txt cryptography==1.5.2
+# hashin -r dependency-requirements.txt cryptography==1.5.2
# sets the new certbot-auto pinned version of cryptography to 1.5.2
argparse==1.4.0 \
@@ -65,9 +65,9 @@ cryptography==1.5.3 \
enum34==1.1.2 \
--hash=sha256:2475d7fcddf5951e92ff546972758802de5260bf409319a9f1934e6bbc8b1dc7 \
--hash=sha256:35907defb0f992b75ab7788f65fedc1cf20ffa22688e0e6f6f12afc06b3ea501
-funcsigs==0.4 \
- --hash=sha256:ff5ad9e2f8d9e5d1e8bbfbcf47722ab527cf0d51caeeed9da6d0f40799383fde \
- --hash=sha256:d83ce6df0b0ea6618700fe1db353526391a8a3ada1b7aba52fed7a61da772033
+funcsigs==1.0.2 \
+ --hash=sha256:330cc27ccbf7f1e992e69fef78261dc7c6569012cf397db8d3de0234e6c937ca \
+ --hash=sha256:a7bb0f2cf3a3fd1ab2732cb49eba4252c2af4240442415b4abce3b87022a8f50
idna==2.0 \
--hash=sha256:9b2fc50bd3c4ba306b9651b69411ef22026d4d8335b93afc2214cef1246ce707 \
--hash=sha256:16199aad938b290f5be1057c0e1efc6546229391c23cea61ca940c115f7d3d3b
@@ -162,24 +162,6 @@ zope.interface==4.1.3 \
--hash=sha256:928138365245a0e8869a5999fbcc2a45475a0a6ed52a494d60dbdc540335fedd \
--hash=sha256:0d841ba1bb840eea0e6489dc5ecafa6125554971f53b5acb87764441e61bceba \
--hash=sha256:b09c8c1d47b3531c400e0195697f1414a63221de6ef478598a4f1460f7d9a392
-mock==1.0.1 \
- --hash=sha256:b839dd2d9c117c701430c149956918a423a9863b48b09c90e30a6013e7d2f44f \
- --hash=sha256:8f83080daa249d036cbccfb8ae5cc6ff007b88d6d937521371afabe7b19badbc
-letsencrypt==0.7.0 \
- --hash=sha256:105a5fb107e45bcd0722eb89696986dcf5f08a86a321d6aef25a0c7c63375ade \
- --hash=sha256:c36e532c486a7e92155ee09da54b436a3c420813ec1c590b98f635d924720de9
-
-# THE LINES BELOW ARE EDITED BY THE RELEASE SCRIPT; ADD ALL DEPENDENCIES ABOVE.
-
-acme==0.13.0 \
- --hash=sha256:103ce8bed43aad1a9655ed815df09bbeab86ee16cc82137b44d9dac68faa394f \
- --hash=sha256:7489b3e20d02da0a389aedb82408ffb6b76294e41d833db85591b9f779539815
-certbot==0.13.0 \
- --hash=sha256:65d0d9d158972aff7746d4ef80a20465a14c54ae8bcb879216970c2a1b34503c \
- --hash=sha256:f63ad7747edaca2fb7d60c28882e44d2f48ff1cca9b9c7c251ad47e2189c00f3
-certbot-apache==0.13.0 \
- --hash=sha256:22f7c1dc93439384c0874960081d66957910c6dc737a9facbd9fcbc46c545874 \
- --hash=sha256:b43b04b53005e7218a09a0ba4d97581fab369e929472fa49fb55d29d0ab54589
-certbot-nginx==0.13.0 \
- --hash=sha256:9d0ab4eeb98b0ebad70ba116b32268342ad343d82d64990a652ff8072959b044 \
- --hash=sha256:f026a8faee8397a22c5d4a7623a6ef7c7e780ed63a3bdf9940f43f7823aa2a72
+mock==2.0.0 \
+ --hash=sha256:5ce3c71c5545b472da17b72268978914d0252980348636840bd34a00b5cc96c1 \
+ --hash=sha256:b158b6df76edd239b8208d481dc46b6afd45a846b7812ff0ce58971cf5bc8bba
diff --git a/letsencrypt-auto-source/pieces/letsencrypt-requirements.txt b/letsencrypt-auto-source/pieces/letsencrypt-requirements.txt
new file mode 100644
index 000000000..8e745c9cd
--- /dev/null
+++ b/letsencrypt-auto-source/pieces/letsencrypt-requirements.txt
@@ -0,0 +1,10 @@
+# Contains the requirements for the letsencrypt package.
+#
+# Since the letsencrypt package depends on certbot and using pip with hashes
+# requires that all installed packages have hashes listed, this allows
+# dependency-requirements.txt to be used without requiring a hash for a
+# (potentially unreleased) Certbot package.
+
+letsencrypt==0.7.0 \
+ --hash=sha256:105a5fb107e45bcd0722eb89696986dcf5f08a86a321d6aef25a0c7c63375ade \
+ --hash=sha256:c36e532c486a7e92155ee09da54b436a3c420813ec1c590b98f635d924720de9
diff --git a/letshelp-certbot/letshelp_certbot/apache.py b/letshelp-certbot/letshelp_certbot/apache.py
index 2391a30bb..b13057ca5 100755
--- a/letshelp-certbot/letshelp_certbot/apache.py
+++ b/letshelp-certbot/letshelp_certbot/apache.py
@@ -183,19 +183,22 @@ def setup_tempdir(args):
config_fd.write(args.config_file + "\n")
proc = subprocess.Popen([args.apache_ctl, "-v"],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ universal_newlines=True)
with open(os.path.join(tempdir, "version"), "w") as version_fd:
version_fd.write(proc.communicate()[0])
proc = subprocess.Popen([args.apache_ctl, "-d", args.server_root, "-f",
args.config_file, "-M"],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ universal_newlines=True)
with open(os.path.join(tempdir, "modules"), "w") as modules_fd:
modules_fd.write(proc.communicate()[0])
proc = subprocess.Popen([args.apache_ctl, "-d", args.server_root, "-f",
args.config_file, "-t", "-D", "DUMP_VHOSTS"],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ universal_newlines=True)
with open(os.path.join(tempdir, "vhosts"), "w") as vhosts_fd:
vhosts_fd.write(proc.communicate()[0])
@@ -231,7 +234,8 @@ def locate_config(apache_ctl):
"""
try:
proc = subprocess.Popen([apache_ctl, "-V"],
- stdout=subprocess.PIPE, stderr=subprocess.PIPE)
+ stdout=subprocess.PIPE, stderr=subprocess.PIPE,
+ universal_newlines=True)
output, _ = proc.communicate()
except OSError:
sys.exit(_NO_APACHECTL)
diff --git a/letshelp-certbot/setup.cfg b/letshelp-certbot/setup.cfg
new file mode 100644
index 000000000..2a9acf13d
--- /dev/null
+++ b/letshelp-certbot/setup.cfg
@@ -0,0 +1,2 @@
+[bdist_wheel]
+universal = 1
diff --git a/letshelp-certbot/setup.py b/letshelp-certbot/setup.py
index b26ab41fe..3ce442b3e 100644
--- a/letshelp-certbot/setup.py
+++ b/letshelp-certbot/setup.py
@@ -33,6 +33,11 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Security',
'Topic :: System :: Installation/Setup',
diff --git a/setup.cfg b/setup.cfg
index 8d68bac30..3b4dbaf87 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -1,3 +1,6 @@
+[bdist_wheel]
+universal = 1
+
[easy_install]
zip_ok = false
diff --git a/setup.py b/setup.py
index 0e8d19a22..80050a2c9 100644
--- a/setup.py
+++ b/setup.py
@@ -36,7 +36,6 @@ version = meta['version']
# https://github.com/pypa/pip/issues/988 for more info.
install_requires = [
'acme=={0}'.format(version),
- 'argparse',
# We technically need ConfigArgParse 0.10.0 for Python 2.6 support, but
# saying so here causes a runtime error against our temporary fork of 0.9.3
# in which we added 2.6 support (see #2243), so we relax the requirement.
@@ -56,6 +55,13 @@ install_requires = [
'zope.interface',
]
+# env markers cause problems with older pip and setuptools
+if sys.version_info < (2, 7):
+ install_requires.extend([
+ 'argparse',
+ 'ordereddict',
+ ])
+
dev_extras = [
# Pin astroid==1.3.5, pylint==1.4.2 as a workaround for #289
'astroid==1.3.5',
@@ -70,7 +76,8 @@ dev_extras = [
docs_extras = [
'repoze.sphinx.autointerface',
- 'Sphinx>=1.0', # autodoc_member_order = 'bysource', autodoc_default_flags
+ # autodoc_member_order = 'bysource', autodoc_default_flags, and #4686
+ 'Sphinx >=1.0,<=1.5.6',
'sphinx_rtd_theme',
]
@@ -94,6 +101,11 @@ setup(
'Programming Language :: Python :: 2',
'Programming Language :: Python :: 2.6',
'Programming Language :: Python :: 2.7',
+ 'Programming Language :: Python :: 3',
+ 'Programming Language :: Python :: 3.3',
+ 'Programming Language :: Python :: 3.4',
+ 'Programming Language :: Python :: 3.5',
+ 'Programming Language :: Python :: 3.6',
'Topic :: Internet :: WWW/HTTP',
'Topic :: Security',
'Topic :: System :: Installation/Setup',
diff --git a/tests/boulder-fetch.sh b/tests/boulder-fetch.sh
index d9a979667..60538362e 100755
--- a/tests/boulder-fetch.sh
+++ b/tests/boulder-fetch.sh
@@ -13,6 +13,7 @@ fi
cd ${BOULDERPATH}
FAKE_DNS=$(ifconfig docker0 | grep "inet addr:" | cut -d: -f2 | awk '{ print $1}')
[ -z "$FAKE_DNS" ] && FAKE_DNS=$(ifconfig docker0 | grep "inet " | xargs | cut -d ' ' -f 2)
+[ -z "$FAKE_DNS" ] && FAKE_DNS=$(ip addr show dev docker0 | grep "inet " | xargs | cut -d ' ' -f 2 | cut -d '/' -f 1)
[ -z "$FAKE_DNS" ] && echo Unable to find the IP for docker0 && exit 1
sed -i "s/FAKE_DNS: .*/FAKE_DNS: ${FAKE_DNS}/" docker-compose.yml
docker-compose up -d
diff --git a/tests/boulder-integration.sh b/tests/boulder-integration.sh
index 08c482676..d86a6fb8c 100755
--- a/tests/boulder-integration.sh
+++ b/tests/boulder-integration.sh
@@ -203,7 +203,9 @@ common revoke --cert-path "$root/conf/live/le2.wtf/cert.pem" \
common unregister
-if type nginx;
+# Most CI systems set this variable to true.
+# If the tests are running as part of CI, Nginx should be available.
+if ${CI:-false} || type nginx;
then
. ./certbot-nginx/tests/boulder-integration.sh
fi
diff --git a/tests/lock_test.py b/tests/lock_test.py
new file mode 100644
index 000000000..4bb2865b4
--- /dev/null
+++ b/tests/lock_test.py
@@ -0,0 +1,238 @@
+"""Tests to ensure the lock order is preserved."""
+import atexit
+import functools
+import logging
+import os
+import re
+import shutil
+import subprocess
+import sys
+import tempfile
+
+from certbot import lock
+from certbot import util
+
+from certbot.tests import util as test_util
+
+
+logger = logging.getLogger(__name__)
+
+
+def main():
+ """Run the lock tests."""
+ dirs, base_cmd = set_up()
+ for subcommand in ('certonly', 'install', 'renew', 'run',):
+ logger.info('Testing subcommand: %s', subcommand)
+ test_command(base_cmd + [subcommand], dirs)
+ logger.info('Lock test ran successfully.')
+
+
+def set_up():
+ """Prepare tests to be run.
+
+ Logging is set up and temporary directories are set up to contain a
+ basic Certbot and Nginx configuration. The directories are returned
+ in the order they should be locked by Certbot. If the Nginx plugin
+ is expected to work on the system, the Nginx directory is included,
+ otherwise, it is not.
+
+ A Certbot command is also created that uses the temporary
+ directories. The returned command can be used to test different
+ subcommands by appending the desired command to the end.
+
+ :returns: directories and command
+ :rtype: `tuple` of `list`
+
+ """
+ logging.basicConfig(format='%(message)s', level=logging.INFO)
+ config_dir, logs_dir, work_dir, nginx_dir = set_up_dirs()
+ command = set_up_command(config_dir, logs_dir, work_dir, nginx_dir)
+
+ dirs = [logs_dir, config_dir, work_dir]
+ # Travis and Circle CI set CI to true so we
+ # will always test Nginx's lock during CI
+ if os.environ.get('CI') == 'true' or util.exe_exists('nginx'):
+ dirs.append(nginx_dir)
+ else:
+ logger.warning('Skipping Nginx lock tests')
+
+ return dirs, command
+
+
+def set_up_dirs():
+ """Set up directories for tests.
+
+ A temporary directory is created to contain the config, log, work,
+ and nginx directories. A sample renewal configuration is created in
+ the config directory and a basic Nginx config is placed in the Nginx
+ directory. The temporary directory containing all of these
+ directories is deleted when the program exits.
+
+ :return value: config, log, work, and nginx directories
+ :rtype: `tuple` of `str`
+
+ """
+ temp_dir = tempfile.mkdtemp()
+ logger.debug('Created temporary directory: %s', temp_dir)
+ atexit.register(functools.partial(shutil.rmtree, temp_dir))
+
+ config_dir = os.path.join(temp_dir, 'config')
+ logs_dir = os.path.join(temp_dir, 'logs')
+ work_dir = os.path.join(temp_dir, 'work')
+ nginx_dir = os.path.join(temp_dir, 'nginx')
+
+ for directory in (config_dir, logs_dir, work_dir, nginx_dir,):
+ os.mkdir(directory)
+
+ test_util.make_lineage(config_dir, 'sample-renewal.conf')
+ set_up_nginx_dir(nginx_dir)
+
+ return config_dir, logs_dir, work_dir, nginx_dir
+
+
+def set_up_nginx_dir(root_path):
+ """Create a basic Nginx configuration in nginx_dir.
+
+ :param str root_path: where the Nginx server root should be placed
+
+ """
+ # Get the root of the git repository
+ repo_root = check_call('git rev-parse --show-toplevel'.split()).strip()
+ conf_script = os.path.join(
+ repo_root, 'certbot-nginx', 'tests', 'boulder-integration.conf.sh')
+ # boulder-integration.conf.sh uses the root environment variable as
+ # the Nginx server root when writing paths
+ os.environ['root'] = root_path
+ with open(os.path.join(root_path, 'nginx.conf'), 'w') as f:
+ f.write(check_call(['/bin/sh', conf_script]))
+ del os.environ['root']
+
+
+def set_up_command(config_dir, logs_dir, work_dir, nginx_dir):
+ """Build the Certbot command to run for testing.
+
+ You can test different subcommands by appending the desired command
+ to the returned list.
+
+ :param str config_dir: path to the configuration directory
+ :param str logs_dir: path to the logs directory
+ :param str work_dir: path to the work directory
+ :param str nginx_dir: path to the nginx directory
+
+ :returns: certbot command to execute for testing
+ :rtype: `list` of `str`
+
+ """
+ return (
+ 'certbot --cert-path {0} --key-path {1} --config-dir {2} '
+ '--logs-dir {3} --work-dir {4} --nginx-server-root {5} --debug '
+ '--force-renewal --nginx --verbose '.format(
+ test_util.vector_path('cert.pem'),
+ test_util.vector_path('rsa512_key.pem'),
+ config_dir, logs_dir, work_dir, nginx_dir).split())
+
+
+def test_command(command, directories):
+ """Assert Certbot acquires locks in a specific order.
+
+ command is run repeatedly testing that Certbot acquires locks on
+ directories in the order they appear in the parameter directories.
+
+ :param list command: Certbot command to execute
+ :param list directories: list of directories Certbot should fail
+ to acquire the lock on in sorted order
+
+ """
+ locks = [lock.lock_dir(directory) for directory in directories]
+ for dir_path, dir_lock in zip(directories, locks):
+ check_error(command, dir_path)
+ dir_lock.release()
+
+
+def check_error(command, dir_path):
+ """Run command and verify it fails to acquire the lock for dir_path.
+
+ :param str command: certbot command to run
+ :param str dir_path: path to directory containing the lock Certbot
+ should fail on
+
+ """
+ ret, out, err = subprocess_call(command)
+ if ret == 0:
+ report_failure("Certbot didn't exit with a nonzero status!", out, err)
+
+ match = re.search("Please see the logfile '(.*)' for more details", err)
+ if match is not None:
+ # Get error output from more verbose logfile
+ with open(match.group(1)) as f:
+ err = f.read()
+
+ pattern = 'A lock on {0}.* is held by another process'.format(dir_path)
+ if not re.search(pattern, err):
+ err_msg = 'Directory path {0} not in error output!'.format(dir_path)
+ report_failure(err_msg, out, err)
+
+
+def check_call(args):
+ """Simple imitation of subprocess.check_call.
+
+ This function is only available in subprocess in Python 2.7+.
+
+ :param list args: program and it's arguments to be run
+
+ :returns: stdout output
+ :rtype: str
+
+ """
+ exit_code, out, err = subprocess_call(args)
+ if exit_code:
+ report_failure('Command exited with a nonzero status!', out, err)
+ return out
+
+
+def report_failure(err_msg, out, err):
+ """Report a subprocess failure and exit.
+
+ :param str err_msg: error message to report
+ :param str out: stdout output
+ :param str err: stderr output
+
+ """
+ logger.fatal(err_msg)
+ log_output(logging.INFO, out, err)
+ sys.exit(err_msg)
+
+
+def subprocess_call(args):
+ """Run a command with subprocess and return the result.
+
+ :param list args: program and it's arguments to be run
+
+ :returns: return code, stdout output, stderr output
+ :rtype: tuple
+
+ """
+ process = subprocess.Popen(args, stdout=subprocess.PIPE,
+ stderr=subprocess.PIPE, universal_newlines=True)
+ out, err = process.communicate()
+ logger.debug('Return code was %d', process.returncode)
+ log_output(logging.DEBUG, out, err)
+ return process.returncode, out, err
+
+
+def log_output(level, out, err):
+ """Logs stdout and stderr output at the requested level.
+
+ :param int level: logging level to use
+ :param str out: stdout output
+ :param str err: stderr output
+
+ """
+ if out:
+ logger.log(level, 'Stdout output was:\n%s', out)
+ if err:
+ logger.log(level, 'Stderr output was:\n%s', err)
+
+
+if __name__ == "__main__":
+ main()
diff --git a/tools/_venv_common.sh b/tools/_venv_common.sh
index ddbb02c62..20ed4c034 100755
--- a/tools/_venv_common.sh
+++ b/tools/_venv_common.sh
@@ -19,7 +19,7 @@ virtualenv --no-site-packages --setuptools $VENV_NAME $VENV_ARGS
# invocations use latest
pip install -U pip
pip install -U setuptools
-pip install "$@"
+./tools/pip_install.sh "$@"
set +x
echo "Please run the following command to activate developer environment:"
diff --git a/tools/deactivate.py b/tools/deactivate.py
new file mode 100644
index 000000000..5facc8436
--- /dev/null
+++ b/tools/deactivate.py
@@ -0,0 +1,49 @@
+"""
+Given an ACME account key as input, deactivate the account.
+
+This can be useful if you created an account with a non-Certbot client and now
+want to deactivate it.
+
+Private key should be in PKCS#8 PEM form.
+
+To provide the URL for the ACME server you want to use, set it in the $DIRECTORY
+environment variable, e.g.:
+
+DIRECTORY=https://acme-staging.api.letsencrypt.org/directory python \
+ deactivate.py private_key.pem
+"""
+import os
+import sys
+
+from cryptography.hazmat.backends import default_backend
+from cryptography.hazmat.primitives.asymmetric import rsa
+from cryptography.hazmat.primitives import serialization
+
+from acme import client as acme_client
+from acme import errors as acme_errors
+from acme import jose
+from acme import messages
+
+DIRECTORY = os.getenv('DIRECTORY', 'http://localhost:4000/directory')
+
+if len(sys.argv) != 2:
+ print("Usage: python deactivate.py private_key.pem")
+ sys.exit(1)
+
+data = open(sys.argv[1], "r").read()
+key = jose.JWKRSA(key=serialization.load_pem_private_key(
+ data, None, default_backend()))
+
+net = acme_client.ClientNetwork(key, verify_ssl=False,
+ user_agent="acme account deactivator")
+
+client = acme_client.Client(DIRECTORY, key=key, net=net)
+try:
+ # We expect this to fail and give us a Conflict response with a Location
+ # header pointing at the account's URL.
+ client.register()
+except acme_errors.ConflictError as e:
+ location = e.location
+if location is None:
+ raise "Key was not previously registered (but now is)."
+client.deactivate_registration(messages.RegistrationResource(uri=location))
diff --git a/tools/pip_install.sh b/tools/pip_install.sh
new file mode 100755
index 000000000..8a58f9e48
--- /dev/null
+++ b/tools/pip_install.sh
@@ -0,0 +1,13 @@
+#!/bin/sh -e
+# pip installs packages using Certbot's requirements file as constraints
+
+# get the root of the Certbot repo
+repo_root=$(git rev-parse --show-toplevel)
+requirements="$repo_root/letsencrypt-auto-source/pieces/dependency-requirements.txt"
+constraints=$(mktemp)
+trap "rm -f $constraints" EXIT
+# extract pinned requirements without hashes
+sed -n -e 's/^\([^[:space:]]*==[^[:space:]]*\).*$/\1/p' $requirements > $constraints
+
+# install the requested packages using the pinned requirements as constraints
+pip install --constraint $constraints "$@"
diff --git a/tools/release.sh b/tools/release.sh
index 1da11fe2c..be8e56353 100755
--- a/tools/release.sh
+++ b/tools/release.sh
@@ -45,7 +45,8 @@ export GPG_TTY=$(tty)
PORT=${PORT:-1234}
# subpackages to be released
-SUBPKGS=${SUBPKGS:-"acme certbot-apache certbot-nginx"}
+SUBPKGS_NO_CERTBOT="acme certbot-apache certbot-nginx"
+SUBPKGS="certbot $SUBPKGS_NO_CERTBOT"
subpkgs_modules="$(echo $SUBPKGS | sed s/-/_/g)"
# certbot_compatibility_test is not packaged because:
# - it is not meant to be used by anyone else than Certbot devs
@@ -83,21 +84,22 @@ git checkout "$RELEASE_BRANCH"
SetVersion() {
ver="$1"
- for pkg_dir in $SUBPKGS certbot-compatibility-test
+ # bumping Certbot's version number is done differently
+ for pkg_dir in $SUBPKGS_NO_CERTBOT certbot-compatibility-test
do
sed -i "s/^version.*/version = '$ver'/" $pkg_dir/setup.py
done
sed -i "s/^__version.*/__version__ = '$ver'/" certbot/__init__.py
# interactive user input
- git add -p certbot $SUBPKGS certbot-compatibility-test
+ git add -p $SUBPKGS certbot-compatibility-test
}
SetVersion "$version"
echo "Preparing sdists and wheels"
-for pkg_dir in . $SUBPKGS
+for pkg_dir in . $SUBPKGS_NO_CERTBOT
do
cd $pkg_dir
@@ -118,7 +120,7 @@ done
mkdir "dist.$version"
mv dist "dist.$version/certbot"
-for pkg_dir in $SUBPKGS
+for pkg_dir in $SUBPKGS_NO_CERTBOT
do
mv $pkg_dir/dist "dist.$version/$pkg_dir/"
done
@@ -140,7 +142,7 @@ pip install -U pip
pip install \
--no-cache-dir \
--extra-index-url http://localhost:$PORT \
- certbot $SUBPKGS
+ $SUBPKGS
# stop local PyPI
kill $!
cd ~-
@@ -160,29 +162,26 @@ mkdir kgs
kgs="kgs/$version"
pip freeze | tee $kgs
pip install nose
-for module in certbot $subpkgs_modules ; do
+for module in $subpkgs_modules ; do
echo testing $module
nosetests $module
done
cd ~-
# pin pip hashes of the things we just built
-for pkg in acme certbot certbot-apache certbot-nginx ; do
+for pkg in $SUBPKGS ; do
echo $pkg==$version \\
pip hash dist."$version/$pkg"/*.{whl,gz} | grep "^--hash" | python2 -c 'from sys import stdin; input = stdin.read(); print " ", input.replace("\n--hash", " \\\n --hash"),'
-done > /tmp/hashes.$$
+done > letsencrypt-auto-source/pieces/certbot-requirements.txt
deactivate
-if ! wc -l /tmp/hashes.$$ | grep -qE "^\s*12 " ; then
+# there should be one requirement specifier and two hashes for each subpackage
+expected_count=$(expr $(echo $SUBPKGS | wc -w) \* 3)
+if ! wc -l letsencrypt-auto-source/pieces/certbot-requirements.txt | grep -qE "^\s*$expected_count " ; then
echo Unexpected pip hash output
exit 1
fi
-# perform hideous surgery on requirements.txt...
-head -n -12 letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt > /tmp/req.$$
-cat /tmp/hashes.$$ >> /tmp/req.$$
-cp /tmp/req.$$ letsencrypt-auto-source/pieces/letsencrypt-auto-requirements.txt
-
# ensure we have the latest built version of leauto
letsencrypt-auto-source/build.py
diff --git a/tools/sphinx-quickstart.sh b/tools/sphinx-quickstart.sh
new file mode 100755
index 000000000..d67c45b6f
--- /dev/null
+++ b/tools/sphinx-quickstart.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+
+if [ $# -ne 1 ]; then
+ echo "Usage: $(basename $0) project-name"
+ exit 1
+fi
+
+PROJECT=$1
+
+yes "n" | sphinx-quickstart --dot _ --project $PROJECT --author "Certbot Project" -v 0 --release 0 --language en --suffix .rst --master index --ext-autodoc --ext-intersphinx --ext-todo --ext-coverage --ext-viewcode --makefile --batchfile $PROJECT/docs
+
+cd $PROJECT/docs
+sed -i -e "s|\# import os|import os|" conf.py
+sed -i -e "s|\# needs_sphinx = '1.0'|needs_sphinx = '1.0'|" conf.py
+sed -i -e "s|intersphinx_mapping = {'https://docs.python.org/': None}|intersphinx_mapping = {\n 'python': ('https://docs.python.org/', None),\n 'acme': ('https://acme-python.readthedocs.org/en/latest/', None),\n 'certbot': ('https://certbot.eff.org/docs/', None),\n}|" conf.py
+sed -i -e "s|html_theme = 'alabaster'|\n# http://docs.readthedocs.org/en/latest/theme.html#how-do-i-use-this-locally-and-on-read-the-docs\n# on_rtd is whether we are on readthedocs.org\non_rtd = os.environ.get('READTHEDOCS', None) == 'True'\nif not on_rtd: # only import and set the theme if we're building docs locally\n import sphinx_rtd_theme\n html_theme = 'sphinx_rtd_theme'\n html_theme_path = [sphinx_rtd_theme.get_html_theme_path()]\n# otherwise, readthedocs.org uses their theme by default, so no need to specify it|" conf.py
+sed -i -e "s|# Add any paths that contain templates here, relative to this directory.|autodoc_member_order = 'bysource'\nautodoc_default_flags = ['show-inheritance', 'private-members']\n\n# Add any paths that contain templates here, relative to this directory.|" conf.py
+sed -i -e "s|# The name of the Pygments (syntax highlighting) style to use.|default_role = 'py:obj'\n\n# The name of the Pygments (syntax highlighting) style to use.|" conf.py
+echo "/_build/" >> .gitignore
+echo "=================
+API Documentation
+=================
+
+.. toctree::
+ :glob:
+
+ api/**" > api.rst
+sed -i -e "s| :caption: Contents:| :caption: Contents:\n\n.. toctree::\n :maxdepth: 1\n\n api\n\n.. automodule:: ${PROJECT//-/_}\n :members:|" index.rst
+
+echo "Suggested next steps:
+* Add API docs to: $PROJECT/docs/api/
+* Run: git add $PROJECT/docs"
diff --git a/tools/venv.sh b/tools/venv.sh
index c9d8fdb9d..2a59737a7 100755
--- a/tools/venv.sh
+++ b/tools/venv.sh
@@ -14,6 +14,11 @@ fi
-e acme[dev] \
-e .[dev,docs] \
-e certbot-apache \
+ -e certbot-dns-cloudflare \
+ -e certbot-dns-cloudxns \
+ -e certbot-dns-digitalocean \
+ -e certbot-dns-dnsimple \
+ -e certbot-dns-google \
-e certbot-nginx \
-e letshelp-certbot \
-e certbot-compatibility-test
diff --git a/tools/venv3.sh b/tools/venv3.sh
index 08358aa48..a0c98126e 100755
--- a/tools/venv3.sh
+++ b/tools/venv3.sh
@@ -13,6 +13,11 @@ fi
-e acme[dev] \
-e .[dev,docs] \
-e certbot-apache \
+ -e certbot-dns-cloudflare \
+ -e certbot-dns-cloudxns \
+ -e certbot-dns-digitalocean \
+ -e certbot-dns-dnsimple \
+ -e certbot-dns-google \
-e certbot-nginx \
-e letshelp-certbot \
-e certbot-compatibility-test
diff --git a/tox.cover.sh b/tox.cover.sh
index 7243c4708..f7064f918 100755
--- a/tox.cover.sh
+++ b/tox.cover.sh
@@ -9,7 +9,7 @@
# -e makes sure we fail fast and don't submit coveralls submit
if [ "xxx$1" = "xxx" ]; then
- pkgs="certbot acme certbot_apache certbot_nginx letshelp_certbot"
+ pkgs="certbot acme certbot_apache certbot_dns_cloudflare certbot_dns_cloudxns certbot_dns_digitalocean certbot_dns_dnsimple certbot_dns_google certbot_nginx letshelp_certbot"
else
pkgs="$@"
fi
@@ -21,6 +21,16 @@ cover () {
min=100
elif [ "$1" = "certbot_apache" ]; then
min=100
+ elif [ "$1" = "certbot_dns_cloudflare" ]; then
+ min=98
+ elif [ "$1" = "certbot_dns_cloudxns" ]; then
+ min=99
+ elif [ "$1" = "certbot_dns_digitalocean" ]; then
+ min=98
+ elif [ "$1" = "certbot_dns_dnsimple" ]; then
+ min=98
+ elif [ "$1" = "certbot_dns_google" ]; then
+ min=99
elif [ "$1" = "certbot_nginx" ]; then
min=97
elif [ "$1" = "letshelp_certbot" ]; then
diff --git a/tox.ini b/tox.ini
index d393bb610..89eef5c76 100644
--- a/tox.ini
+++ b/tox.ini
@@ -9,20 +9,62 @@ envlist = modification,py{26,33,34,35,36},cover,lint
# nosetest -v => more verbose output, allows to detect busy waiting
# loops, especially on Travis
-[testenv]
+[base]
+# wraps pip install to use pinned versions of dependencies
+pip_install = {toxinidir}/tools/pip_install.sh
# packages installed separately to ensure that downstream deps problems
# are detected, c.f. #1002
-commands =
- pip install -e acme[dev]
+core_commands =
+ {[base]pip_install} -e acme[dev]
nosetests -v acme --processes=-1
- pip install -e .[dev]
+ {[base]pip_install} -e .[dev]
nosetests -v certbot --processes=-1 --process-timeout=100
- pip install -e certbot-apache
+core_install_args = -e acme[dev] -e .[dev]
+core_paths = acme/acme certbot
+
+plugin_commands =
+ {[base]pip_install} -e certbot-apache
nosetests -v certbot_apache --processes=-1 --process-timeout=80
- pip install -e certbot-nginx
+ {[base]pip_install} -e certbot-nginx
nosetests -v certbot_nginx --processes=-1
- pip install -e letshelp-certbot
+plugin_install_args = -e certbot-apache -e certbot-nginx
+plugin_paths = certbot-apache/certbot_apache certbot-nginx/certbot_nginx
+
+dns_plugin_commands =
+ pip install -e certbot-dns-cloudflare
+ nosetests -v certbot_dns_cloudflare --processes=-1
+ pip install -e certbot-dns-digitalocean
+ nosetests -v certbot_dns_digitalocean --processes=-1
+ pip install -e certbot-dns-google
+ nosetests -v certbot_dns_google --processes=-1
+dns_plugin_install_args = -e certbot-dns-cloudflare -e certbot-dns-digitalocean -e certbot-dns-google
+dns_plugin_paths = certbot-dns-cloudflare/certbot_dns_cloudflare certbot-dns-digitalocean/certbot_dns_digitalocean certbot-dns-google/certbot_dns_google
+
+lexicon_dns_plugin_commands =
+ pip install -e certbot-dns-cloudxns
+ nosetests -v certbot_dns_cloudxns --processes=-1
+ pip install -e certbot-dns-dnsimple
+ nosetests -v certbot_dns_dnsimple --processes=-1
+lexicon_dns_plugin_install_args = -e certbot-dns-cloudxns -e certbot-dns-dnsimple
+lexicon_dns_plugin_paths = certbot-dns-cloudxns/certbot_dns_cloudxns certbot-dns-dnsimple/certbot_dns_dnsimple
+
+compatibility_install_args = -e certbot-compatibility-test
+compatibility_paths = certbot-compatibility-test/certbot_compatibility_test
+
+other_commands =
+ {[base]pip_install} -e letshelp-certbot
nosetests -v letshelp_certbot --processes=-1
+ python tests/lock_test.py
+other_install_args = -e letshelp-certbot
+other_paths = letshelp-certbot/letshelp_certbot tests/lock_test.py
+
+[testenv]
+commands =
+ {[base]core_commands}
+ {[base]plugin_commands}
+ {[base]dns_plugin_commands}
+ {[base]lexicon_dns_plugin_commands}
+ {[base]other_commands}
setenv =
PYTHONPATH = {toxinidir}
@@ -40,15 +82,26 @@ deps =
py{26,27}-oldest: PyOpenSSL==0.13
py{26,27}-oldest: requests<=2.11.1
+[testenv:py26]
+commands =
+ {[base]core_commands}
+ {[base]plugin_commands}
+ {[base]dns_plugin_commands}
+ {[base]other_commands}
+
+[testenv:py26-oldest]
+commands =
+ {[testenv:py26]commands}
+
[testenv:py27_install]
basepython = python2.7
commands =
- pip install -e acme[dev] -e .[dev] -e certbot-apache -e certbot-nginx -e letshelp-certbot
+ {[base]pip_install} {[base]core_install_args} {[base]plugin_install_args} {[base]dns_plugin_install_args} {[base]lexicon_dns_plugin_install_args} {[base]other_install_args}
[testenv:cover]
basepython = python2.7
commands =
- pip install -e acme[dev] -e .[dev] -e certbot-apache -e certbot-nginx -e letshelp-certbot
+ {[base]pip_install} {[base]core_install_args} {[base]plugin_install_args} {[base]dns_plugin_install_args} {[base]lexicon_dns_plugin_install_args} {[base]other_install_args}
./tox.cover.sh
[testenv:lint]
@@ -58,25 +111,25 @@ basepython = python2.7
# duplicate code checking; if one of the commands fails, others will
# continue, but tox return code will reflect previous error
commands =
- pip install -q -e acme[dev] -e .[dev] -e certbot-apache -e certbot-nginx -e certbot-compatibility-test -e letshelp-certbot
- pylint --reports=n --rcfile=.pylintrc acme/acme certbot certbot-apache/certbot_apache certbot-nginx/certbot_nginx certbot-compatibility-test/certbot_compatibility_test letshelp-certbot/letshelp_certbot
+ {[base]pip_install} -q {[base]core_install_args} {[base]plugin_install_args} {[base]dns_plugin_install_args} {[base]lexicon_dns_plugin_install_args} {[base]compatibility_install_args} {[base]other_install_args}
+ pylint --reports=n --rcfile=.pylintrc {[base]core_paths} {[base]plugin_paths} {[base]dns_plugin_paths} {[base]lexicon_dns_plugin_paths} {[base]compatibility_paths} {[base]other_paths}
[testenv:mypy]
basepython = python3.4
commands =
- pip install mypy
- pip install -q -e acme[dev] -e .[dev] -e certbot-apache -e certbot-nginx -e certbot-compatibility-test -e letshelp-certbot
- mypy --py2 --ignore-missing-imports acme/acme certbot certbot-apache/certbot_apache certbot-nginx/certbot_nginx certbot-compatibility-test/certbot_compatibility_test letshelp-certbot/letshelp_certbot
+ {[base]pip_install} mypy
+ {[base]pip_install} -q {[base]core_install_args} {[base]plugin_install_args} {[base]dns_plugin_install_args} {[base]lexicon_dns_plugin_install_args} {[base]compatibility_install_args} {[base]other_install_args}
+ mypy --py2 --ignore-missing-imports {[base]core_paths} {[base]plugin_paths} {[base]dns_plugin_paths} {[base]lexicon_dns_plugin_paths} {[base]compatibility_paths} {[base]other_paths}
[testenv:apacheconftest]
#basepython = python2.7
commands =
- pip install -e acme -e .[dev] -e certbot-apache -e certbot-nginx -e certbot-compatibility-test -e letshelp-certbot
+ {[base]pip_install} {[base]core_install_args} {[base]plugin_install_args} {[base]compatibility_install_args} {[base]other_install_args}
{toxinidir}/certbot-apache/certbot_apache/tests/apache-conf-files/apache-conf-test --debian-modules
[testenv:nginxroundtrip]
commands =
- pip install -e acme[dev] -e .[dev] -e certbot-nginx
+ {[base]pip_install} {[base]core_install_args} -e certbot-nginx
python certbot-compatibility-test/nginx/roundtrip.py certbot-compatibility-test/nginx/nginx-roundtrip-testdata
# This is a duplication of the command line in testenv:le_auto to