diff --git a/CHANGELOG.md b/CHANGELOG.md index 7486bbb..ab1da28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,8 @@ +## v0.0.5 - 2023-10-xx - refactor API code + +* Use a common code base for ConstellixClient and SonarClient +* Prepare the authZ code for v4 (Authorization: Bearer) + ## v0.0.4 - 2023-09-24 - ordering is important * Fix for presistent changes in dynamic rule ordering diff --git a/octodns_constellix/__init__.py b/octodns_constellix/__init__.py index 4a22029..fe22adb 100644 --- a/octodns_constellix/__init__.py +++ b/octodns_constellix/__init__.py @@ -6,7 +6,7 @@ import hmac import logging import time -from base64 import b64encode, standard_b64encode +from base64 import standard_b64encode from collections import defaultdict from pycountry_convert import country_alpha2_to_continent_code @@ -20,30 +20,29 @@ __VERSION__ = '0.0.4' -class ConstellixClientException(ProviderException): +class ConstellixAPIException(ProviderException): pass -class ConstellixClientBadRequest(ConstellixClientException): - def __init__(self, resp): - errors = '\n - '.join(resp.json()['errors']) +class ConstellixAPIBadRequest(ConstellixAPIException): + def __init__(self, data): + errors = '\n - '.join(data.get('errors', [])) super().__init__(f'\n - {errors}') -class ConstellixClientUnauthorized(ConstellixClientException): +class ConstellixAPIUnauthorized(ConstellixAPIException): def __init__(self): super().__init__('Unauthorized') -class ConstellixClientNotFound(ConstellixClientException): +class ConstellixAPINotFound(ConstellixAPIException): def __init__(self): super().__init__('Not Found') -class ConstellixClient(object): - BASE = 'https://api.dns.constellix.com/v1' - - def __init__(self, log, api_key, secret_key, ratelimit_delay=0.0): +class ConstellixAPI(object): + def __init__(self, base_url, log, api_key, secret_key, ratelimit_delay): + self.base_url = base_url self.log = log self.api_key = api_key self.secret_key = secret_key @@ -51,47 +50,81 @@ def __init__(self, log, api_key, secret_key, ratelimit_delay=0.0): self._sess = Session() self._sess.headers.update( { - 'x-cnsdns-apiKey': self.api_key, - 'User-Agent': f'octodns/{octodns_version} octodns-constellix/{__VERSION__}', + 'User-Agent': f'octodns/{octodns_version} octodns-constellix/{__VERSION__}' } ) - self._domains = None - self._pools = {'A': None, 'AAAA': None, 'CNAME': None} - self._geofilters = None - def _current_time(self): - return str(int(time.time() * 1000)) + def _url(self, path): + return f'{self.base_url}{path}' - def _hmac_hash(self, now): - return hmac.new( - self.secret_key.encode('utf-8'), - now.encode('utf-8'), - digestmod=hashlib.sha1, - ).digest() + def _get_json(self, response): + try: + return response.json() + except: + raise ConstellixAPIBadRequest({'errors': [response.text]}) + + def _auth_header(self): + now = str(int(time.time() * 1000)) + hmac_text = str( + standard_b64encode( + hmac.new( + self.secret_key.encode('utf-8'), + now.encode('utf-8'), + digestmod=hashlib.sha1, + ).digest() + ), + 'UTF-8', + ) + auth_token = f'{self.api_key}:{hmac_text}:{now}' + + if self.base_url.endswith('/v4'): + return {'Authorization': f'Bearer {auth_token}'} + else: + return {'x-cns-security-token': auth_token} def _request(self, method, path, params=None, data=None): - self.log.debug("Call _request %s %s", method, path) - now = self._current_time() - hmac_hash = self._hmac_hash(now) + url = self._url(path) + self.log.debug('Call _request %s %s', method, url) - headers = { - 'x-cnsdns-hmac': b64encode(hmac_hash), - 'x-cnsdns-requestDate': now, - } + headers = self._auth_header() - url = f'{self.BASE}{path}' resp = self._sess.request( method, url, headers=headers, params=params, json=data ) - if resp.status_code == 400: - raise ConstellixClientBadRequest(resp) - if resp.status_code == 401: - raise ConstellixClientUnauthorized() - if resp.status_code == 404: - raise ConstellixClientNotFound() + + status_code = resp.status_code + headers = resp.headers + + if status_code == 400: + raise ConstellixAPIBadRequest(self._get_json(resp)) + if status_code == 401: + raise ConstellixAPIUnauthorized() + if status_code == 404: + raise ConstellixAPINotFound() resp.raise_for_status() + time.sleep(self.ratelimit_delay) - return resp + + return resp, self._get_json(resp), headers + + +class ConstellixClient(ConstellixAPI): + def __init__(self, log, api_key, secret_key, ratelimit_delay=0.0): + super().__init__( + 'https://api.dns.constellix.com/v1', + log, + api_key, + secret_key, + ratelimit_delay, + ) + + self._domains = None + self._pools = {'A': None, 'AAAA': None, 'CNAME': None} + self._geofilters = None + + def _request(self, method, path, params=None, data=None): + response, data, headers = super()._request(method, path, params, data) + return response @property def domains(self): @@ -108,7 +141,7 @@ def domains(self): def domain(self, name): zone_id = self.domains.get(name, False) if not zone_id: - raise ConstellixClientNotFound() + raise ConstellixAPINotFound() path = f'/domains/{zone_id}' return self._request('GET', path).json() @@ -137,7 +170,7 @@ def _absolutize_value(self, value, zone_name): def records(self, zone_name): zone_id = self.domains.get(zone_name, False) if not zone_id: - raise ConstellixClientNotFound() + raise ConstellixAPINotFound() path = f'/domains/{zone_id}/records' resp = self._request('GET', path).json() @@ -215,7 +248,7 @@ def pool_update(self, pool_id, data): try: data = self._request('PUT', path, data=data).json() - except ConstellixClientBadRequest as e: + except ConstellixAPIBadRequest as e: message = str(e) if not message or ( "no changes to save" not in message @@ -269,7 +302,7 @@ def geofilter_update(self, geofilter_id, data): try: data = self._request('PUT', path, data=data).json() - except ConstellixClientBadRequest as e: + except ConstellixAPIBadRequest as e: message = str(e) if not message or ( "no changes to save" not in message @@ -288,80 +321,20 @@ def geofilter_delete(self, geofilter_id): return resp -class SonarClientException(ProviderException): - pass - - -class SonarClientBadRequest(SonarClientException): - def __init__(self, resp): - errors = resp.text - super().__init__(f'\n - {errors}') - - -class SonarClientUnauthorized(SonarClientException): - def __init__(self): - super().__init__('Unauthorized') - - -class SonarClientNotFound(SonarClientException): - def __init__(self): - super().__init__('Not Found') - - -class SonarClient(object): - BASE = 'https://api.sonar.constellix.com/rest/api' - +class SonarClient(ConstellixAPI): def __init__(self, log, api_key, secret_key, ratelimit_delay=0.0): - self.log = log - self.api_key = api_key - self.secret_key = secret_key - self.ratelimit_delay = ratelimit_delay - self._sess = Session() - self._sess.headers = { - 'Content-Type': 'application/json', - 'User-Agent': f'octodns/{octodns_version} octodns-constellix/{__VERSION__}', - } + super().__init__( + 'https://api.sonar.constellix.com/rest/api', + log, + api_key, + secret_key, + ratelimit_delay, + ) self._agents = None self._checks = {'tcp': None, 'http': None} - def _current_time_ms(self): - return str(int(time.time() * 1000)) - - def _hmac_hash(self, now): - digester = hmac.new( - bytes(self.secret_key, "UTF-8"), bytes(now, "UTF-8"), hashlib.sha1 - ) - signature = digester.digest() - hmac_text = str(standard_b64encode(signature), "UTF-8") - return hmac_text - def _request(self, method, path, params=None, data=None): - now = self._current_time_ms() - hmac_text = self._hmac_hash(now) - - headers = { - 'x-cns-security-token': "{}:{}:{}".format( - self.api_key, hmac_text, now - ) - } - - url = f'{self.BASE}{path}' - resp = self._sess.request( - method, url, headers=headers, params=params, json=data - ) - if resp.status_code == 400: - raise SonarClientBadRequest(resp) - if resp.status_code == 401: - raise SonarClientUnauthorized() - if resp.status_code == 404: - raise SonarClientNotFound() - resp.raise_for_status() - - if self.ratelimit_delay >= 1.0: - self.log.info("Waiting for Sonar Rate Limit Delay") - elif self.ratelimit_delay > 0.0: - self.log.debug("Waiting for Sonar Rate Limit Delay") - time.sleep(self.ratelimit_delay) + resp, data, headers = super()._request(method, path, params, data) return resp @@ -702,7 +675,7 @@ def zone_records(self, zone): if zone.name not in self._zone_records: try: self._zone_records[zone.name] = self._client.records(zone.name) - except ConstellixClientNotFound: + except ConstellixAPINotFound: return [] return self._zone_records[zone.name] @@ -872,12 +845,13 @@ def _create_update_dynamic_healthchecks(self, record, pool_data): health_data = {} for pool_name, pool in pool_data.items(): for value in pool['values']: - check_name = '{}-{}'.format(pool_name, value['value']) + check_value = value['value'] + check_name = f'{pool_name}-{check_value}' check_obj = self._create_update_check( pool_type=record._type, check_name=check_name, check_type=healthcheck['sonar_type'].lower(), - value=value['value'], + value=check_value, port=healthcheck['sonar_port'], interval=healthcheck['sonar_interval'], sites=check_sites, @@ -1159,7 +1133,7 @@ def _apply(self, plan): try: self._client.domain(desired.name) - except ConstellixClientNotFound: + except ConstellixAPINotFound: self.log.debug('_apply: no matching zone, creating domain') self._client.domain_create(desired.name[:-1]) diff --git a/tests/test_provider_constellix.py b/tests/test_provider_constellix.py index 78cb0ce..5e5207c 100644 --- a/tests/test_provider_constellix.py +++ b/tests/test_provider_constellix.py @@ -3,6 +3,7 @@ # import logging +import time from os.path import dirname, join from unittest import TestCase from unittest.mock import Mock, PropertyMock, call @@ -17,8 +18,9 @@ from octodns.zone import Zone from octodns_constellix import ( + ConstellixAPI, + ConstellixAPIBadRequest, ConstellixClient, - ConstellixClientBadRequest, ConstellixProvider, ) @@ -2057,7 +2059,7 @@ def test_dynamic_record_updates(self): plan = provider.plan(wanted) self.assertEqual(1, len(plan.changes)) - with self.assertRaises(ConstellixClientBadRequest): + with self.assertRaises(ConstellixAPIBadRequest): provider.apply(plan) # Now what happens if an error happens that we can't handle @@ -2092,7 +2094,7 @@ def test_dynamic_record_updates(self): plan = provider.plan(wanted) self.assertEqual(1, len(plan.changes)) - with self.assertRaises(ConstellixClientBadRequest): + with self.assertRaises(ConstellixAPIBadRequest): provider.apply(plan) def test_pools_that_are_notfound(self): @@ -2284,7 +2286,7 @@ def test_unsupported_multi_warn(self): class TestConstellixClient(TestCase): def test_unknown_geofilter(self): log = logging.getLogger('client') - client = ConstellixClient(log, 'test', 'api', 'secret') + client = ConstellixClient(log, 'api', 'secret') resp = Mock() resp.json = Mock() @@ -2293,3 +2295,56 @@ def test_unknown_geofilter(self): resp.json.side_effect = resp_side_effect self.assertIsNone(client.geofilter_by_id(9999999)) + + +class TestConstellixAPI(TestCase): + def test_v1_v2_sonar_auth(self): + log = logging.getLogger('client') + api_key = 'api' + secret_key = 'test' + time.time = Mock(return_value=1234567890.1234) + for base_url in [ + 'https://api.dns.constellix.com/v1', + 'https://api.dns.constellix.com/v2', + 'https://api.sonar.constellix.com/rest/api', + ]: + api = ConstellixAPI(base_url, log, api_key, secret_key, 0.0) + + auth_header = api._auth_header() + auth_token = auth_header.get('x-cns-security-token', None) + self.assertIsNotNone(auth_token) + self.assertIsNone(auth_header.get('authorization', None)) + + parts = auth_token.split(':') + self.assertEqual(3, len(parts)) + + self.assertEqual(api_key, parts[0]) + self.assertEqual('S5VaK5DN7gpfTGh1975BlT6xw7k=', parts[1]) + self.assertEqual(str(int(time.time() * 1000)), parts[2]) + + def test_v4_sonar_auth(self): + pass + log = logging.getLogger('client') + api_key = 'api' + secret_key = 'test' + time.time = Mock(return_value=1234567890.1234) + + api = ConstellixAPI( + 'https://api.dns.constellix.com/v4', log, api_key, secret_key, 0.0 + ) + + auth_header = api._auth_header() + auth_value = auth_header.get('Authorization', None) + bearer_prefix = 'Bearer ' + self.assertIsNotNone(auth_value) + self.assertIsNone(auth_header.get('x-cns-security-token', None)) + self.assertTrue(auth_value.startswith(bearer_prefix)) + + auth_token = auth_value[len(bearer_prefix) :] + + parts = auth_token.split(':') + self.assertEqual(3, len(parts)) + + self.assertEqual(api_key, parts[0]) + self.assertEqual('S5VaK5DN7gpfTGh1975BlT6xw7k=', parts[1]) + self.assertEqual(str(int(time.time() * 1000)), parts[2])