certbot-dns-google: enable automatic credential lookup on google cloud (#5117)

- when no credentials are passed it will try to get valid credentials
using the google metadata service
- this is a feature of the google SDK, so we don't need to handle that
explicitly
- previous behaviour with a credentials file is retained
This commit is contained in:
Christian Becker
2017-09-25 12:17:15 -07:00
committed by Brad Warren
parent 1ce813c3cc
commit 36d5221bac
4 changed files with 109 additions and 12 deletions
@@ -10,7 +10,7 @@ Named Arguments
======================================== ===================================== ======================================== =====================================
``--dns-google-credentials`` Google Cloud Platform credentials_ ``--dns-google-credentials`` Google Cloud Platform credentials_
JSON file. JSON file.
(Required) (Required - Optional on Google Compute Engine)
``--dns-google-propagation-seconds`` The number of seconds to wait for DNS ``--dns-google-propagation-seconds`` The number of seconds to wait for DNS
to propagate before asking the ACME to propagate before asking the ACME
server to verify the DNS record. server to verify the DNS record.
@@ -21,8 +21,8 @@ Named Arguments
Credentials Credentials
----------- -----------
Use of this plugin requires a configuration file containing Google Cloud Use of this plugin requires Google Cloud Platform API credentials
Platform API credentials for an account with the following permissions: for an account with the following permissions:
* ``dns.changes.create`` * ``dns.changes.create``
* ``dns.changes.get`` * ``dns.changes.get``
@@ -33,7 +33,12 @@ Platform API credentials for an account with the following permissions:
Google provides instructions for `creating a service account <https://developers Google provides instructions for `creating a service account <https://developers
.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount>`_ and .google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount>`_ and
`information about the required permissions <https://cloud.google.com/dns/access `information about the required permissions <https://cloud.google.com/dns/access
-control#permissions_and_roles>`_. -control#permissions_and_roles>`_. If you're running on Google Compute Engine,
you can `assign the service account to the instance <https://cloud.google.com/
compute/docs/access/create-enable-service-accounts-for-instances>`_ which
is running certbot. A credentials file is not required in this case, as they
are automatically obtained by certbot through the `metadata service
<https://cloud.google.com/compute/docs/storing-retrieving-metadata>`_ .
.. code-block:: json .. code-block:: json
:name: credentials.json :name: credentials.json
@@ -2,6 +2,7 @@
import json import json
import logging import logging
import httplib2
import zope.interface import zope.interface
from googleapiclient import discovery from googleapiclient import discovery
from googleapiclient import errors as googleapiclient_errors from googleapiclient import errors as googleapiclient_errors
@@ -15,6 +16,8 @@ logger = logging.getLogger(__name__)
ACCT_URL = 'https://developers.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount' ACCT_URL = 'https://developers.google.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount'
PERMISSIONS_URL = 'https://cloud.google.com/dns/access-control#permissions_and_roles' PERMISSIONS_URL = 'https://cloud.google.com/dns/access-control#permissions_and_roles'
METADATA_URL = 'http://metadata.google.internal/computeMetadata/v1/'
METADATA_HEADERS = {'Metadata-Flavor': 'Google'}
@zope.interface.implementer(interfaces.IAuthenticator) @zope.interface.implementer(interfaces.IAuthenticator)
@@ -39,14 +42,27 @@ class Authenticator(dns_common.DNSAuthenticator):
add('credentials', add('credentials',
help=('Path to Google Cloud DNS service account JSON file. (See {0} for' + 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' + 'information about creating a service account and {1} for information about the' +
'required permissions.)').format(ACCT_URL, PERMISSIONS_URL)) 'required permissions.)').format(ACCT_URL, PERMISSIONS_URL),
default=None)
def more_info(self): # pylint: disable=missing-docstring,no-self-use 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 ' + \ return 'This plugin configures a DNS TXT record to respond to a dns-01 challenge using ' + \
'the Google Cloud DNS API.' 'the Google Cloud DNS API.'
def _setup_credentials(self): def _setup_credentials(self):
self._configure_file('credentials', 'path to Google Cloud DNS service account JSON file') if self.conf('credentials') is None:
try:
# use project_id query to check for availability of google metadata server
# we won't use the result but know we're not on GCP when an exception is thrown
_GoogleClient.get_project_id()
except (ValueError, httplib2.ServerNotFoundError):
raise errors.PluginError('Unable to get Google Cloud Metadata and no credentials'
' specified. Automatic credential lookup is only '
'available on Google Cloud Platform. Please configure'
' credentials using --dns-google-credentials <file>')
else:
self._configure_file('credentials',
'path to Google Cloud DNS service account JSON file')
dns_common.validate_file_permissions(self.conf('credentials')) dns_common.validate_file_permissions(self.conf('credentials'))
@@ -65,13 +81,18 @@ class _GoogleClient(object):
Encapsulates all communication with the Google Cloud DNS API. Encapsulates all communication with the Google Cloud DNS API.
""" """
def __init__(self, account_json): def __init__(self, account_json=None):
scopes = ['https://www.googleapis.com/auth/ndev.clouddns.readwrite'] scopes = ['https://www.googleapis.com/auth/ndev.clouddns.readwrite']
if account_json is not None:
credentials = ServiceAccountCredentials.from_json_keyfile_name(account_json, scopes) 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: with open(account_json) as account:
self.project_id = json.load(account)['project_id'] self.project_id = json.load(account)['project_id']
else:
credentials = None
self.project_id = self.get_project_id()
self.dns = discovery.build('dns', 'v1', credentials=credentials, cache_discovery=False)
def add_txt_record(self, domain, record_name, record_content, record_ttl): def add_txt_record(self, domain, record_name, record_content, record_ttl):
""" """
@@ -183,3 +204,24 @@ class _GoogleClient(object):
raise errors.PluginError('Unable to determine managed zone for {0} using zone names: {1}.' raise errors.PluginError('Unable to determine managed zone for {0} using zone names: {1}.'
.format(domain, zone_dns_name_guesses)) .format(domain, zone_dns_name_guesses))
@staticmethod
def get_project_id():
"""
Query the google metadata service for the current project ID
This only works on Google Cloud Platform
:raises ServerNotFoundError: Not running on Google Compute or DNS not available
:raises ValueError: Server is found, but response code is not 200
:returns: project id
"""
url = '{0}project/project-id'.format(METADATA_URL)
# Request an access token from the metadata server.
http = httplib2.Http()
r, content = http.request(url, headers=METADATA_HEADERS)
if r.status != 200:
raise ValueError("Invalid status code: {0}".format(r))
return content
@@ -5,8 +5,10 @@ import unittest
import mock import mock
from googleapiclient.errors import Error from googleapiclient.errors import Error
from httplib2 import ServerNotFoundError
from certbot import errors from certbot import errors
from certbot.errors import PluginError
from certbot.plugins import dns_test_common from certbot.plugins import dns_test_common
from certbot.plugins.dns_test_common import DOMAIN from certbot.plugins.dns_test_common import DOMAIN
from certbot.tests import util as test_util from certbot.tests import util as test_util
@@ -50,6 +52,11 @@ class AuthenticatorTest(test_util.TempDirTestCase, dns_test_common.BaseAuthentic
expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)] expected = [mock.call.del_txt_record(DOMAIN, '_acme-challenge.'+DOMAIN, mock.ANY, mock.ANY)]
self.assertEqual(expected, self.mock_client.mock_calls) self.assertEqual(expected, self.mock_client.mock_calls)
@mock.patch('httplib2.Http.request', side_effect=ServerNotFoundError)
def test_without_auth(self, unused_mock):
self.config.google_credentials = None
self.assertRaises(PluginError, self.auth.perform, [self.achall])
class GoogleClientTest(unittest.TestCase): class GoogleClientTest(unittest.TestCase):
record_name = "foo" record_name = "foo"
@@ -74,11 +81,24 @@ class GoogleClientTest(unittest.TestCase):
return client, mock_changes return client, mock_changes
@mock.patch('googleapiclient.discovery.build')
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google.dns_google._GoogleClient.get_project_id')
def test_client_without_credentials(self, get_project_id_mock, credential_mock,
unused_discovery_mock):
from certbot_dns_google.dns_google import _GoogleClient
_GoogleClient(None)
self.assertFalse(credential_mock.called)
self.assertTrue(get_project_id_mock.called)
@mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name') @mock.patch('oauth2client.service_account.ServiceAccountCredentials.from_json_keyfile_name')
@mock.patch('certbot_dns_google.dns_google.open', @mock.patch('certbot_dns_google.dns_google.open',
mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True) mock.mock_open(read_data='{"project_id": "' + PROJECT_ID + '"}'), create=True)
def test_add_txt_record(self, unused_credential_mock): @mock.patch('certbot_dns_google.dns_google._GoogleClient.get_project_id')
def test_add_txt_record(self, get_project_id_mock, credential_mock):
client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}]) client, changes = self._setUp_client_with_mock([{'managedZones': [{'id': self.zone}]}])
credential_mock.assert_called_once_with('/not/a/real/path.json', mock.ANY)
self.assertFalse(get_project_id_mock.called)
client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) client.add_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
@@ -197,6 +217,34 @@ class GoogleClientTest(unittest.TestCase):
client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl) client.del_txt_record(DOMAIN, self.record_name, self.record_content, self.record_ttl)
def test_get_project_id(self):
from certbot_dns_google.dns_google import _GoogleClient
response = DummyResponse()
response.status = 200
with mock.patch('httplib2.Http.request', return_value=(response, 1234)):
project_id = _GoogleClient.get_project_id()
self.assertEqual(project_id, 1234)
failed_response = DummyResponse()
failed_response.status = 404
with mock.patch('httplib2.Http.request',
return_value=(failed_response, "some detailed http error response")):
self.assertRaises(ValueError, _GoogleClient.get_project_id)
with mock.patch('httplib2.Http.request', side_effect=ServerNotFoundError):
self.assertRaises(ServerNotFoundError, _GoogleClient.get_project_id)
class DummyResponse(object):
"""
Dummy object to create a fake HTTPResponse (the actual one requires a socket and we only
need the status attribute)
"""
def __init__(self):
self.status = 200
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() # pragma: no cover unittest.main() # pragma: no cover
+2
View File
@@ -19,6 +19,8 @@ install_requires = [
# will tolerate; see #2599: # will tolerate; see #2599:
'setuptools>=1.0', 'setuptools>=1.0',
'zope.interface', 'zope.interface',
# already a dependency of google-api-python-client, but added for consistency
'httplib2'
] ]
docs_extras = [ docs_extras = [