From 36d5221bacbffb15e37ea1e904dbd6f41caa09b2 Mon Sep 17 00:00:00 2001 From: Christian Becker Date: Mon, 25 Sep 2017 21:17:15 +0200 Subject: [PATCH] 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 --- .../certbot_dns_google/__init__.py | 13 +++-- .../certbot_dns_google/dns_google.py | 56 ++++++++++++++++--- .../certbot_dns_google/dns_google_test.py | 50 ++++++++++++++++- certbot-dns-google/setup.py | 2 + 4 files changed, 109 insertions(+), 12 deletions(-) diff --git a/certbot-dns-google/certbot_dns_google/__init__.py b/certbot-dns-google/certbot_dns_google/__init__.py index 26685206c..7349a7696 100644 --- a/certbot-dns-google/certbot_dns_google/__init__.py +++ b/certbot-dns-google/certbot_dns_google/__init__.py @@ -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 `_ and `information about the required permissions `_. +-control#permissions_and_roles>`_. If you're running on Google Compute Engine, +you can `assign the service account to the instance `_ which +is running certbot. A credentials file is not required in this case, as they +are automatically obtained by certbot through the `metadata service +`_ . .. code-block:: json :name: credentials.json diff --git a/certbot-dns-google/certbot_dns_google/dns_google.py b/certbot-dns-google/certbot_dns_google/dns_google.py index 39811782e..37fd6b0de 100644 --- a/certbot-dns-google/certbot_dns_google/dns_google.py +++ b/certbot-dns-google/certbot_dns_google/dns_google.py @@ -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 ') + 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 diff --git a/certbot-dns-google/certbot_dns_google/dns_google_test.py b/certbot-dns-google/certbot_dns_google/dns_google_test.py index 95e3347a1..85649fc7f 100644 --- a/certbot-dns-google/certbot_dns_google/dns_google_test.py +++ b/certbot-dns-google/certbot_dns_google/dns_google_test.py @@ -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 diff --git a/certbot-dns-google/setup.py b/certbot-dns-google/setup.py index a6ee695bf..aff5c5786 100644 --- a/certbot-dns-google/setup.py +++ b/certbot-dns-google/setup.py @@ -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 = [