mirror of
https://github.com/certbot/certbot.git
synced 2026-06-04 22:33:00 -04: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:
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_
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
|
|
|
|||
Loading…
Reference in a new issue