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 21:17:15 +02:00 committed by Brad Warren
parent 1ce813c3cc
commit 36d5221bac
4 changed files with 109 additions and 12 deletions

View file

@ -10,7 +10,7 @@ Named Arguments
======================================== =====================================
``--dns-google-credentials`` Google Cloud Platform credentials_
JSON file.
(Required)
(Required - Optional on Google Compute Engine)
``--dns-google-propagation-seconds`` The number of seconds to wait for DNS
to propagate before asking the ACME
server to verify the DNS record.
@ -21,8 +21,8 @@ Named Arguments
Credentials
-----------
Use of this plugin requires a configuration file containing Google Cloud
Platform API credentials for an account with the following permissions:
Use of this plugin requires Google Cloud Platform API credentials
for an account with the following permissions:
* ``dns.changes.create``
* ``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.com/identity/protocols/OAuth2ServiceAccount#creatinganaccount>`_ and
`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
:name: credentials.json

View file

@ -2,6 +2,7 @@
import json
import logging
import httplib2
import zope.interface
from googleapiclient import discovery
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'
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)
@ -39,16 +42,29 @@ class Authenticator(dns_common.DNSAuthenticator):
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))
'required permissions.)').format(ACCT_URL, PERMISSIONS_URL),
default=None)
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')
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'))
def _perform(self, domain, validation_name, validation):
self._get_google_client().add_txt_record(domain, validation_name, validation, self.ttl)
@ -65,13 +81,18 @@ class _GoogleClient(object):
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']
credentials = ServiceAccountCredentials.from_json_keyfile_name(account_json, scopes)
if account_json is not None:
credentials = ServiceAccountCredentials.from_json_keyfile_name(account_json, scopes)
with open(account_json) as account:
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)
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):
"""
@ -183,3 +204,24 @@ class _GoogleClient(object):
raise errors.PluginError('Unable to determine managed zone for {0} using zone names: {1}.'
.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

View file

@ -5,8 +5,10 @@ import unittest
import mock
from googleapiclient.errors import Error
from httplib2 import ServerNotFoundError
from certbot import errors
from certbot.errors import PluginError
from certbot.plugins import dns_test_common
from certbot.plugins.dns_test_common import DOMAIN
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)]
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):
record_name = "foo"
@ -74,11 +81,24 @@ class GoogleClientTest(unittest.TestCase):
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('certbot_dns_google.dns_google.open',
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}]}])
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)
@ -197,6 +217,34 @@ class GoogleClientTest(unittest.TestCase):
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__":
unittest.main() # pragma: no cover

View file

@ -19,6 +19,8 @@ install_requires = [
# will tolerate; see #2599:
'setuptools>=1.0',
'zope.interface',
# already a dependency of google-api-python-client, but added for consistency
'httplib2'
]
docs_extras = [