diff options
author | Andreas Runfalk <andreas@runfalk.se> | 2017-08-21 13:00:57 +0200 |
---|---|---|
committer | Andreas Runfalk <andreas@runfalk.se> | 2017-08-21 13:00:57 +0200 |
commit | e5593e9fdd0fd19624debca9d5bf3529957ec37b (patch) | |
tree | 36f5833d1fd8089f6ec41b4f35c739cecf7ebb02 | |
parent | 6c4c01061067ff0220b1e0ff132d1c5b1bf0c5ca (diff) | |
download | certbot-loopia-e5593e9fdd0fd19624debca9d5bf3529957ec37b.zip certbot-loopia-e5593e9fdd0fd19624debca9d5bf3529957ec37b.tar.gz certbot-loopia-e5593e9fdd0fd19624debca9d5bf3529957ec37b.tar.bz2 |
Rewrote the entire plugin to match the implementations of other certbot-dns plugins (fixes #2)
-rw-r--r-- | README.rst | 82 | ||||
-rw-r--r-- | certbot_loopia.py | 320 | ||||
-rw-r--r-- | setup.py | 10 |
3 files changed, 113 insertions, 299 deletions
@@ -1,8 +1,7 @@ Loopia DNS Authenticator for Certbot ==================================== -This allows automatic completion of `Certbot's -<https://github.com/certbot/certbot>` DNS01 challange for domains managed on -`Loopia <https://www.loopia.se/>` DNS. +This allows automatic completion of `Certbot's <https://github.com/certbot/certbot>`_ +DNS01 challange for domains managed on `Loopia <https://www.loopia.se/>`_ DNS. Installing @@ -14,46 +13,53 @@ Installing Usage ----- -To use the authenticator you need to provide some required options. - -``--certbot-loopia:auth-user <user>`` *(required)* - API username for Loopia. -``--certbot-loopia:auth-password <password>`` *(required)* - API password for Loopia. - -There are also some optional arguments: - -``--certbot-loopia:auth-time-limit <time>`` - Time limit for local verification. This is the maximum time the authenticator - will try to self-verify before declaring that the DNS update was unsuccessful. - Default: ``30m``. -``--certbot-loopia:auth-time-delay <time>`` - Time delay before first trying to self-verify the result of authentication. - It is recommended to have a delay of at least 30 seconds to prevent the DNS - server from caching that there are no TXT records for the challenge subdomain. - Default: ``1m``. -``--certbot-loopia:auth-retry-interval <time>`` - How frequently to retry self-verification. This is time past since the start - of the previous verification. It is not recommended to choose values smaller - than 10 seconds. Default: ``30s``. - -The format of ``<time>`` is ``AdBhCmDs`` where: ``A``is days, ``B``is hours, -``C``is minutes and ``D`` is seconds. Note that ``A``, ``B``, ``C`` and ``D`` -must be integers. The units ``d``, ``h`` and ``m`` are required while ``s`` is -optional. Any value-unit pair may be omitted, but they must be ordered from most -to least significant unit. Examples of valid ``<time>`` expressions are: - -- ``42`` or ``42s`` for 42 seconds -- ``1m30s`` or ``1m30`` for 1.5 minutes -- ``1h`` for 1 hour -- ``1d12h`` for 1.5 days +To use the authenticator you need to provide some required options: + +``--certbot-loopia:credentials`` *(required)* + INI file with ``user`` and ``password`` for your Loopia API user. ``user`` + normally has the format ``user@loopiaapi``. + +The credential file must have the folling format: + +.. code-block:: + + certbot_loopia:auth_user = user@loopiaapi + certbot_loopia:auth_password = passwordgoeshere + +For safety reasons the file must not be world readable. You can solve this by +running: + +.. code-block:: + + $ chmod 600 credentials.ini Known issues ------------ - Due to caching on Loopia's side it can take up to 15 minutes before changes - are visible. The plugin will by default retry self-verification for at least - 30 minutes before sending the actual verification request to the ACME server. + propagates. Therefore the plugin will wait 15 minutes before contacting the + ACME server. + + +Changelog +--------- + +Version 0.2.0 +~~~~~~~~~~~~~ +Released 21st August 2017 + +- Rewrote plugin to match the implementation of ``certbot-dns-*`` plugins +- Updated dependency requirements since the old release was completely broken + for newer ``acme`` and ``certbot`` + (see `issue #2 <https://github.com/runfalk/certbot-loopia/issues/2>`_) + + +Version 0.1.0 +~~~~~~~~~~~~~ +Released 10th May 2017 + +- Initial release + Disclaimer ---------- diff --git a/certbot_loopia.py b/certbot_loopia.py index 7792802..02fa0ed 100644 --- a/certbot_loopia.py +++ b/certbot_loopia.py @@ -3,68 +3,19 @@ import itertools import re import zope.interface -from acme.challenges import DNS01 -from acme.dns_resolver import DNS_AVAILABLE -from acme.errors import DependencyError -from acme.jose.b64 import b64encode -from certbot.plugins.common import Plugin +from certbot.plugins.dns_common import base_domain_name_guesses, DNSAuthenticator from certbot.interfaces import IAuthenticator, IPluginFactory from datetime import datetime, timedelta -from loopialib import DnsRecord, Loopia +from loopialib import DnsRecord, Loopia, split_domain from time import sleep -logger = logging.getLogger(__name__) - - -def parse_time(time): - pattern = r"^(?:(?P<d>\d+)d)?(?:(?P<h>\d+)h)?(?:(?P<m>\d+)m)?(?:(?P<s>\d+)s?)?$" - multipliers = { - "d": 86400, - "h": 3600, - "m": 60, - "s": 1, - } - - match = re.match(pattern, time) - if match is None: - raise ValueError("Invalid time format") - - return sum( - int(v) * multipliers[k] - for k, v in match.groupdict().items() - if v is not None) - - -class Domain(object): - def __init__(self, subdomain=None, name=None, top_domain=None): - self.subdomain = subdomain - self.name = name - self.top_domain = top_domain - - @classmethod - def from_str(cls, domain): - parts = domain.split(".") - # TODO: Does not handle .co.uk style top domains - subdomain = ".".join(parts[:-2]) or None - name = parts[-2] - top_domain = parts[-1] - - return cls(subdomain, name, top_domain) - - @property - def domain(self): - return "{}.{}".format(self.name, self.top_domain) - - def __str__(self): - if self.subdomain is None: - return self.domain - return "{}.{}".format(self.subdomain, self.domain) +logger = logging.getLogger(__name__) @zope.interface.implementer(IAuthenticator) @zope.interface.provider(IPluginFactory) -class LoopiaAuthenticator(Plugin): +class LoopiaAuthenticator(DNSAuthenticator): """ Loopia DNS ACME authenticator. @@ -74,77 +25,20 @@ class LoopiaAuthenticator(Plugin): #: Short description of plugin description = __doc__.strip().split("\n", 1)[0] - @classmethod - def add_parser_arguments(cls, add): - add("user", action="store", help="Loopia API username.") - add("password", action="store", help="Loopia API password.") - add( - "time-limit", - action="store", - default="30m", - help="Time limit for retries on local challenge verification.") - add( - "time-delay", - action="store", - default="1m", - help="Time before trying to verify challange locally.") - add( - "retry-interval", - action="store", - default="30s", - help="Time between each localverification retry.") + #: TTL for the validation TXT record + ttl = 30 - @property - def time_limit(self): - return parse_time(self.conf("time-limit")) + def __init__(self, *args, **kwargs): + super(LoopiaAuthenticator, self).__init__(*args, **kwargs) + self._client = None + self.credentials = None - @property - def time_delay(self): - return parse_time(self.conf("time-delay")) - - @property - def retry_interval(self): - return parse_time(self.conf("retry-interval")) - - @property - def _can_self_verify(self): - return DNS_AVAILABLE - - def prepare(self): - """ - Initializer for plugin - """ - - user = self.conf("user") - password = self.conf("password") - - if not user: - raise KeyError("Username not defined. Call with --{ns}{opt}".format( - ns=self.option_namespace, - opt="user")) - - if not password: - raise KeyError("Password not defined. Call with --{ns}{opt}".format( - ns=self.option_namespace, - opt="password")) - - # Verify correctness of other options - try: - self.time_limit - except ValueError: - raise ValueError("Invalid format for time limit.") - - try: - self.time_delay - except ValueError: - raise ValueError("Invalid format for time delay.") - - try: - self.retry_interval - except ValueError: - raise ValueError("Invalid format for retry interval.") + @classmethod + def add_parser_arguments(cls, add, default_propagation_seconds=15 * 60): + super(LoopiaAuthenticator, cls).add_parser_arguments( + add, default_propagation_seconds) + add("credentials", help="Loopia API credentials INI file.") - self.loopia = Loopia(self.conf("user"), self.conf("password")) def more_info(self): """ @@ -153,139 +47,53 @@ class LoopiaAuthenticator(Plugin): return "\n".join(line[4:] for line in __doc__.strip().split("\n")) - def get_chall_pref(self, domain): - """ - Return a list of all supported challenge types of this plugin. - """ - - return [DNS01] - - def verify_challenge(self, achall, response): - """ - Verify that the given authentication challange has been completed. - """ - - try: - verification_status = response.simple_verify( - achall.chall, - achall.domain, - achall.account_key.public_key()) - except DependencyError: - logger.warning( - "Self verification requires optional " - "dependency `dnspython` to be installed.") - raise - else: - if verification_status: - logger.info("Verification successful") - return True - else: - logger.warning("Self-verify of challenge failed.") - - return False - - def perform_challenge(self, achall): - """ - Complete a given authentication challenge. - - :param achall: A DNS01 challenge - :return: A challenge response object - """ - - response, token = achall.response_and_validation() - - chall_domain = Domain.from_str(achall.validation_domain_name(achall.domain)) - - logger.info("Creating record for {} with token {}".format( - chall_domain, token)) - - dns_record = DnsRecord("TXT", ttl=30, data=token) - - self.loopia.add_zone_record( - dns_record, chall_domain.domain, chall_domain.subdomain) - - # Verify the result of adding the zone record. If the verification fails - # it will retry twice after a delay since changes may not have - # propagated yet. - if not self._can_self_verify: - logger.info(( - "Self-verification is unavailable. Trying to authenticate in " - "{} seconds.").format(self.time_delay)) - sleep(self.time_delay) - return response - - start_time = datetime.now() - max_time = start_time + timedelta(seconds=self.time_limit) - for i in itertools.count(): - logger.debug("Self-verification try #{}".format(i + 1)) - - current_time = datetime.now() - if i == 0: - sleep(self.time_delay) - else: - # Sleep for the remaining time of the - delay = self.retry_interval - ( - current_time - start_time).total_seconds() - sleep(0 if delay < 0 else delay) - - if self.verify_challenge(achall, response): - break - elif current_time < max_time: - seconds = 30 * (i + 1) - logger.info( - "Retrying self-verification in {} seconds.".format(seconds)) - sleep(seconds) - else: - # It is possible that the DNS change did not propagate - # yet, in which case we will make the authentication fail. - logger.warning( - "Unable to verify that the challenge was completed.") - return - - - return response - - def perform(self, achalls): - """ - Call perform_challenge for all challenges in achalls and return the - responses as a list. - - :param achalls: List of challenges to authenticate. - :return: List of challenge responses - """ - - return [self.perform_challenge(achall) for achall in achalls] - - def cleanup_challenge(self, achall): - """ - Restore the side-effects of completing the given challenge. It is - possible to run this function despite all stepss in the challenge not - completing. - - :param achall: Authentication challenge to cleanup after - """ - - chall_domain = Domain.from_str( - achall.validation_domain_name(achall.domain)) - - logger.info("Cleaning up challenge domain {}".format(chall_domain)) - - records = self.loopia.get_zone_records( - chall_domain.domain, chall_domain.subdomain) + def _setup_credentials(self): + self.credentials = self._configure_credentials( + "credentials", + "Loopia credentials INI file", + { + "user": "API username for Loopia account", + "password": "API password for Loopia account", + }, + ) + + def _get_loopia_client(self): + return Loopia( + self.credentials.conf("user"), + self.credentials.conf("password")) + + def _perform(self, domain, validation_name, validation): + loopia = self._get_loopia_client() + domain_parts = split_domain(validation_name) + + dns_record = DnsRecord("TXT", ttl=self.ttl, data=validation) + + logger.debug( + "Creating TXT record for {} on subdomain {}".format(*domain_parts)) + loopia.add_zone_record(dns_record, *domain_parts) + + def _cleanup(self, domain, validation_name, validation): + loopia = self._get_loopia_client() + domain_parts = split_domain(validation_name) + dns_record = DnsRecord("TXT", ttl=self.ttl, data=validation) + + records = loopia.get_zone_records(*domain_parts) + delete_subdomain = True for record in records: - logger.debug("Removing zone record {}".format(record)) - self.loopia.remove_zone_record( - record.id, chall_domain.domain, chall_domain.subdomain) - - logger.debug("Removing subdomain {}".format(chall_domain.subdomain)) - self.loopia.remove_subdomain( - chall_domain.domain, chall_domain.subdomain) - - def cleanup(self, achalls): - """ - Call cleanup_challenge for all challenges in achalls. - - :param achalls: List of authentication challenges to cleanup after. - """ - for achall in achalls: - self.cleanup_challenge(achall) + # Make sure the record we delete actually matches the one we created + if dns_record.replace(id=record.id) == record: + logger.debug("Removing zone record {}".format(record)) + loopia.remove_zone_record(record.id, *domain_parts) + else: + # This happens if there are other zone records on the current + # sub domain. + delete_subdomain = False + + msg = "Record {} prevents the subdomain from being deleted" + logger.debug(msg.format(record)) + + # Delete subdomain if we emptied it completely + if delete_subdomain: + msg = "Removing subdomain {1} on subdomain {0}" + logger.debug(msg.format(*domain_parts)) + loopia.remove_subdomain(*domain_parts) @@ -9,7 +9,7 @@ except: setup( name="certbot-loopia", - version="0.1.0", + version="0.2.0", description="Loopia DNS authentication plugin for Certbot", long_description=long_desc, license="BSD", @@ -18,10 +18,10 @@ setup( url="https://www.github.com/runfalk/certbot-loopia", py_modules=["certbot_loopia"], install_requires=[ - "acme", - "certbot", - "loopialib", - "zope.interface", + "acme>=0.17.0", + "certbot>=0.17.0", + "loopialib>=0.2.0", + "zope.interface>=4.4.0", ], entry_points={ "certbot.plugins": [ |