Skip to content

Commit

Permalink
Merge 278762d into ed23290
Browse files Browse the repository at this point in the history
  • Loading branch information
bahadir committed May 28, 2017
2 parents ed23290 + 278762d commit cea876d
Show file tree
Hide file tree
Showing 21 changed files with 687 additions and 11 deletions.
Expand Up @@ -87,7 +87,7 @@ def add_txt_record(self, domain_name, record_name, record_content):
try:
result = domain.create_new_domain_record(
type='TXT',
name=self._compute_record_name(domain, record_name),
name=dns_common.compute_record_name(domain.name, record_name),
data=record_content)

record_id = result['domain_record']['id']
Expand Down Expand Up @@ -123,7 +123,8 @@ def del_txt_record(self, domain_name, record_name, record_content):

matching_records = [record for record in domain_records
if record.type == 'TXT'
and record.name == self._compute_record_name(domain, record_name)
and record.name == dns_common.compute_record_name(domain.name,
record_name)
and record.data == record_content]
except digitalocean.Error as e:
logger.debug('Error getting DNS records using the DigitalOcean API: %s', e)
Expand Down Expand Up @@ -161,8 +162,3 @@ def _find_domain(self, domain_name):

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]
1 change: 1 addition & 0 deletions certbot-dns-godaddy/certbot_dns_godaddy/__init__.py
@@ -0,0 +1 @@
"""Godaddy DNS Authenticator"""
164 changes: 164 additions & 0 deletions certbot-dns-godaddy/certbot_dns_godaddy/dns_godaddy.py
@@ -0,0 +1,164 @@
"""DNS Authenticator for Godaddy."""
import logging

import godaddypy
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 Godaddy
This Authenticator uses the Godaddy API to fulfill a dns-01 challenge.
"""

description = 'Obtain certs using a DNS TXT record (if you are using Godaddy 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='Godaddy 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 Godaddy API.'

def _setup_credentials(self):
self.credentials = self._configure_credentials(
'credentials',
'Godaddy credentials INI file',
{
'key': 'API key for Godaddy account',
'secret': 'API secret for Godaddy account'
}
)

def _perform(self, domain, validation_name, validation):
self._get_godaddy_client().add_txt_record(domain, validation_name, validation)

def _cleanup(self, domain, validation_name, validation):
self._get_godaddy_client().del_txt_record(domain, validation_name, validation)

def _get_godaddy_client(self):
return _GodaddyClient(self.credentials.conf('key'), self.credentials.conf('secret'))


class _GodaddyClient(object):
"""
Encapsulates all communication with the Godaddy API.
"""

def __init__(self, key, secret):
account = godaddypy.Account(api_key=key, api_secret=secret)
self.client = godaddypy.Client(account)

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 Godaddy
API
"""

try:
domain = self._find_domain(domain_name)
except godaddypy.client.BadResponse as e:
hint = None

if "UNABLE_TO_AUTHENTICATE" in str(e):
hint = 'Did you provide a valid API token?'

logger.debug('Error finding domain using the Godaddy API: %s', e)
raise errors.PluginError('Error finding domain using the Godaddy API: {0}{1}'
.format(e, ' ({0})'.format(hint) if hint else ''))

try:
self.client.add_record(domain, {
'data': record_content,
'name': dns_common.compute_record_name(domain, record_name),
'type': 'TXT'
})
logger.debug('Successfully added TXT record')
except godaddypy.client.BadResponse as e:
logger.debug('Error adding TXT record using the Godaddy API: %s', e)
raise errors.PluginError('Error adding TXT record using the Godaddy 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 godaddypy.client.BadResponse as e:
logger.debug('Error finding domain using the Godaddy API: %s', e)
return

try:
domain_records = self.client.get_records(domain, record_type='TXT')

matching_records = [record for record in domain_records
if record['type'] == 'TXT'
and record['name'] == dns_common.compute_record_name(domain,
record_name)
and record['data'] == record_content]
except godaddypy.client.BadResponse as e:
logger.debug('Error getting DNS records using the Godaddy API: %s', e)
return

for record in matching_records:
try:
logger.debug('Removing TXT record with name: %s', record['name'])
self.client.delete_records(domain, name=record['name'], record_type='TXT')
except godaddypy.client.BadResponse as e:
logger.warn('Error deleting TXT record %s using the Godaddy API: %s',
record['name'], 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: str
:raises certbot.errors.PluginError: if no matching Domain is found.
"""

domain_name_guesses = dns_common.base_domain_name_guesses(domain_name)

domains = self.client.get_domains()

for guess in domain_name_guesses:
matches = [domain for domain in domains if domain == 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))
138 changes: 138 additions & 0 deletions certbot-dns-godaddy/certbot_dns_godaddy/dns_godaddy_test.py
@@ -0,0 +1,138 @@
"""Tests for certbot_dns_godaddy.dns_godaddy."""

import os
import unittest

import godaddypy
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 = godaddypy.client.BadResponse('UNABLE_TO_AUTHENTICATE')
KEY = 'a-key'
SECRET = 'a-secret'


class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthenticatorTest):

def setUp(self):
from certbot_dns_godaddy.dns_godaddy import Authenticator

super(AuthenticatorTest, self).setUp()

path = os.path.join(self.tempdir, 'file.ini')
dns_test_common.write({"godaddy_key": KEY, "godaddy_secret": SECRET}, path)

self.config = mock.MagicMock(godaddy_credentials=path,
godaddy_propagation_seconds=0) # don't wait during tests

self.auth = Authenticator(self.config, "godaddy")

self.mock_client = mock.MagicMock()
# _get_godaddy_client | pylint: disable=protected-access
self.auth._get_godaddy_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 GodaddyClientTest(unittest.TestCase):
id = 1
record_prefix = "_acme-challenge"
record_name = record_prefix + "." + DOMAIN
record_content = "bar"

def setUp(self):
from certbot_dns_godaddy.dns_godaddy import _GodaddyClient

self.godaddy_client = _GodaddyClient(KEY, SECRET)

self.client = mock.MagicMock()
self.godaddy_client.client = self.client

def test_add_txt_record(self):
self.client.get_domains.return_value = [DOMAIN]

self.godaddy_client.add_txt_record(DOMAIN, self.record_name, self.record_content)

self.client.add_record.assert_called_with(DOMAIN, {'type': 'TXT',
'name': self.record_prefix,
'data': self.record_content})

def test_add_txt_record_fail_to_find_domain(self):
self.client.get_domains.return_value = []

self.assertRaises(errors.PluginError,
self.godaddy_client.add_txt_record,
DOMAIN, self.record_name, self.record_content)

def test_add_txt_record_error_finding_domain(self):
self.client.get_domains.side_effect = API_ERROR

self.assertRaises(errors.PluginError,
self.godaddy_client.add_txt_record,
DOMAIN, self.record_name, self.record_content)

def test_add_txt_record_error_creating_record(self):
self.client.get_domains.return_value = [DOMAIN]
self.client.add_record.side_effect = API_ERROR

self.assertRaises(errors.PluginError,
self.godaddy_client.add_txt_record,
DOMAIN, self.record_name, self.record_content)

def test_del_txt_record(self):
records = [
{'type': 'TXT', 'name': 'DIFFERENT', 'data': self.record_content},
{'type': 'TXT', 'name': self.record_prefix, 'data': self.record_content},
{'type': 'TXT', 'name': self.record_prefix, 'data': 'DIFFERENT'},
]

self.client.get_domains.return_value = [DOMAIN]
self.client.get_records.return_value = records

self.godaddy_client.del_txt_record(DOMAIN, self.record_name, self.record_content)

self.client.delete_records.assert_called_with(DOMAIN, name=self.record_prefix,
record_type='TXT')

def test_del_txt_record_error_finding_domain(self):
self.client.get_domains.side_effect = API_ERROR

self.godaddy_client.del_txt_record(DOMAIN, self.record_name, self.record_content)

def test_del_txt_record_error_getting_records(self):
self.client.get_domains.return_value = [DOMAIN]
self.client.get_records.side_effect = API_ERROR

self.godaddy_client.del_txt_record(DOMAIN, self.record_name, self.record_content)

def test_del_txt_record_error_deleting_records(self):
records = [
{'type': 'TXT', 'name': 'DIFFERENT', 'data': self.record_content},
{'type': 'TXT', 'name': self.record_prefix, 'data': self.record_content},
{'type': 'TXT', 'name': self.record_prefix, 'data': 'DIFFERENT'},
]

self.client.get_domains.return_value = [DOMAIN]
self.client.get_records.return_value = records
self.client.delete_records.side_effect = API_ERROR

self.godaddy_client.del_txt_record(DOMAIN, self.record_name, self.record_content)

if __name__ == "__main__":
unittest.main() # pragma: no cover
1 change: 1 addition & 0 deletions certbot-dns-godaddy/docs/.gitignore
@@ -0,0 +1 @@
/_build/
20 changes: 20 additions & 0 deletions certbot-dns-godaddy/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-godaddy
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)
8 changes: 8 additions & 0 deletions certbot-dns-godaddy/docs/api.rst
@@ -0,0 +1,8 @@
=================
API Documentation
=================

.. toctree::
:glob:

api/**
5 changes: 5 additions & 0 deletions certbot-dns-godaddy/docs/api/dns_godaddy.rst
@@ -0,0 +1,5 @@
:mod:`certbot_dns_godaddy.dns_godaddy`
------------------------------------------------

.. automodule:: certbot_dns_godaddy.dns_godaddy
:members:

0 comments on commit cea876d

Please sign in to comment.