mirror of
https://github.com/certbot/certbot.git
synced 2026-08-03 03:32:13 +02:00
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:
committed by
Brad Warren
parent
1ce813c3cc
commit
36d5221bac
@@ -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,16 +42,29 @@ 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'))
|
||||||
|
|
||||||
def _perform(self, domain, validation_name, validation):
|
def _perform(self, domain, validation_name, validation):
|
||||||
self._get_google_client().add_txt_record(domain, validation_name, validation, self.ttl)
|
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.
|
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']
|
||||||
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)
|
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):
|
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
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
Reference in New Issue
Block a user