summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorAndreas Runfalk <andreas@runfalk.se>2017-08-21 13:00:57 +0200
committerAndreas Runfalk <andreas@runfalk.se>2017-08-21 13:00:57 +0200
commite5593e9fdd0fd19624debca9d5bf3529957ec37b (patch)
tree36f5833d1fd8089f6ec41b4f35c739cecf7ebb02
parent6c4c01061067ff0220b1e0ff132d1c5b1bf0c5ca (diff)
downloadcertbot-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.rst82
-rw-r--r--certbot_loopia.py320
-rw-r--r--setup.py10
3 files changed, 113 insertions, 299 deletions
diff --git a/README.rst b/README.rst
index 8ee7662..e0566c3 100644
--- a/README.rst
+++ b/README.rst
@@ -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)
diff --git a/setup.py b/setup.py
index 472833a..cde1ae9 100644
--- a/setup.py
+++ b/setup.py
@@ -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": [