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