338 lines
10 KiB
Python
338 lines
10 KiB
Python
# Copyright 2014 Hewlett-Packard Development Company, L.P.
|
|
#
|
|
# Author: Endre Karlson <endre.karlson@hpe.com>
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License"); you may
|
|
# not use this file except in compliance with the License. You may obtain
|
|
# a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
|
|
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
|
|
# License for the specific language governing permissions and limitations
|
|
# under the License.
|
|
import time
|
|
|
|
from oslo_log import log as logging
|
|
from oslo_serialization import jsonutils
|
|
import requests
|
|
from requests.adapters import HTTPAdapter
|
|
|
|
from designate.backend import base
|
|
import designate.conf
|
|
from designate import exceptions
|
|
from designate import utils
|
|
|
|
CONF = designate.conf.CONF
|
|
LOG = logging.getLogger(__name__)
|
|
CFG_GROUP_NAME = 'backend:dynect'
|
|
|
|
|
|
class DynClientError(exceptions.Backend):
|
|
"""The base exception class for all HTTP exceptions.
|
|
"""
|
|
|
|
def __init__(self, data=None, job_id=None, msgs=None,
|
|
http_status=None, url=None, method=None, details=None):
|
|
self.data = data
|
|
self.job_id = job_id
|
|
self.msgs = msgs
|
|
|
|
self.http_status = http_status
|
|
self.url = url
|
|
self.method = method
|
|
self.details = details
|
|
formatted_string = (
|
|
f'{self.msgs} (HTTP {self.method} to {self.url} - '
|
|
f'{self.http_status}) - {self.details}'
|
|
)
|
|
if job_id:
|
|
formatted_string += f' (Job-ID: {job_id})'
|
|
super().__init__(formatted_string)
|
|
|
|
@staticmethod
|
|
def from_response(response, details=None):
|
|
data = response.json()
|
|
|
|
exc_kwargs = dict(
|
|
data=data['data'],
|
|
job_id=data['job_id'],
|
|
msgs=data['msgs'],
|
|
http_status=response.status_code,
|
|
url=response.url,
|
|
method=response.request.method,
|
|
details=details)
|
|
|
|
for msg in data.get('msgs', []):
|
|
if msg['INFO'].startswith('login:'):
|
|
raise DynClientAuthError(**exc_kwargs)
|
|
elif 'Operation blocked' in msg['INFO']:
|
|
raise DynClientOperationBlocked(**exc_kwargs)
|
|
return DynClientError(**exc_kwargs)
|
|
|
|
|
|
class DynClientAuthError(DynClientError):
|
|
pass
|
|
|
|
|
|
class DynTimeoutError(exceptions.Backend):
|
|
"""
|
|
A job timedout.
|
|
"""
|
|
error_code = 408
|
|
error_type = 'dyn_timeout'
|
|
|
|
|
|
class DynClientOperationBlocked(exceptions.BadRequest, DynClientError):
|
|
error_type = 'operation_blocked'
|
|
|
|
|
|
class DynClient:
|
|
"""
|
|
DynECT service client.
|
|
|
|
https://help.dynect.net/rest/
|
|
"""
|
|
|
|
def __init__(self, customer_name, user_name, password, timeout, timings,
|
|
verify=True, retries=1, pool_maxsize=10, pool_connections=10):
|
|
self.endpoint = 'https://api.dynect.net:443'
|
|
self.api_version = '3.5.6'
|
|
|
|
self.customer_name = customer_name
|
|
self.user_name = user_name
|
|
self.password = password
|
|
|
|
self.times = [] # [("item", starttime, endtime), ...]
|
|
self.timings = timings
|
|
self.timeout = timeout
|
|
|
|
self.authing = False
|
|
self.token = None
|
|
|
|
session = requests.Session()
|
|
session.verify = verify
|
|
|
|
session.headers = {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
'API-Version': self.api_version,
|
|
'User-Agent': 'DynECTClient'
|
|
}
|
|
|
|
adapter = HTTPAdapter(
|
|
max_retries=int(retries),
|
|
pool_maxsize=int(pool_maxsize),
|
|
pool_connections=int(pool_connections),
|
|
pool_block=True
|
|
)
|
|
session.mount(self.endpoint, adapter)
|
|
self.http = session
|
|
|
|
def _http_log_req(self, method, url, kwargs):
|
|
string_parts = [
|
|
"curl -i",
|
|
"-X '%s'" % method,
|
|
"'%s'" % url,
|
|
]
|
|
|
|
for element in kwargs['headers']:
|
|
header = "-H '{}: {}'".format(element, kwargs['headers'][element])
|
|
string_parts.append(header)
|
|
|
|
LOG.debug('REQ: %s', ' '.join(string_parts))
|
|
if 'data' in kwargs:
|
|
LOG.debug('REQ BODY: %s\n', kwargs['data'])
|
|
|
|
def _http_log_resp(self, resp):
|
|
LOG.debug('RESP: [%s] %s\n', resp.status_code, resp.headers)
|
|
|
|
def _request(self, method, url, **kwargs):
|
|
"""
|
|
Low level request helper that actually executes the request towards a
|
|
wanted URL.
|
|
|
|
This does NOT do any authentication.
|
|
"""
|
|
# NOTE: Allow passing the url as just the path or a full url
|
|
if not url.startswith('http'):
|
|
if not url.startswith('/REST'):
|
|
url = '/REST' + url
|
|
url = self.endpoint + url
|
|
|
|
kwargs.setdefault("headers", kwargs.get("headers", {}))
|
|
kwargs['proxies'] = utils.get_proxies()
|
|
|
|
if self.token is not None:
|
|
kwargs['headers']['Auth-Token'] = self.token
|
|
if self.timeout is not None:
|
|
kwargs.setdefault("timeout", self.timeout)
|
|
|
|
data = kwargs.get('data')
|
|
if data is not None:
|
|
kwargs['data'] = data.copy()
|
|
|
|
# NOTE: We don't want to log the credentials (password) that are
|
|
# used in a auth request.
|
|
if 'password' in kwargs['data']:
|
|
kwargs['data']['password'] = '**SECRET**'
|
|
|
|
self._http_log_req(method, url, kwargs)
|
|
|
|
# NOTE: Set it back to the original data and serialize it.
|
|
kwargs['data'] = jsonutils.dump_as_bytes(data)
|
|
else:
|
|
self._http_log_req(method, url, kwargs)
|
|
|
|
start_time = time.monotonic()
|
|
resp = self.http.request(method, url, **kwargs)
|
|
if self.timings:
|
|
self.times.append((f"{method} {url}",
|
|
start_time, time.monotonic()))
|
|
self._http_log_resp(resp)
|
|
|
|
if resp.status_code >= 400:
|
|
LOG.debug('Request returned failure status: %s', resp.status_code)
|
|
raise DynClientError.from_response(resp)
|
|
return resp
|
|
|
|
def request(self, method, url, retries=2, **kwargs):
|
|
if self.token is None and not self.authing:
|
|
self.login()
|
|
|
|
try:
|
|
response = self._request(method, url, **kwargs)
|
|
except DynClientAuthError:
|
|
if retries > 0:
|
|
self.token = None
|
|
retries = retries - 1
|
|
return self.request(method, url, retries, **kwargs)
|
|
else:
|
|
raise
|
|
|
|
return response.json()
|
|
|
|
def login(self):
|
|
self.authing = True
|
|
data = {
|
|
'customer_name': self.customer_name,
|
|
'user_name': self.user_name,
|
|
'password': self.password
|
|
}
|
|
response = self.post('/Session', data=data)
|
|
self.token = response['data']['token']
|
|
self.authing = False
|
|
|
|
def logout(self):
|
|
self.delete('/Session')
|
|
self.token = None
|
|
|
|
def post(self, *args, **kwargs):
|
|
response = self.request('POST', *args, **kwargs)
|
|
return response
|
|
|
|
def put(self, *args, **kwargs):
|
|
response = self.request('PUT', *args, **kwargs)
|
|
return response
|
|
|
|
def delete(self, *args, **kwargs):
|
|
response = self.request('DELETE', *args, **kwargs)
|
|
return response
|
|
|
|
|
|
class DynECTBackend(base.Backend):
|
|
"""
|
|
Support for DynECT as a secondary DNS.
|
|
"""
|
|
__plugin_name__ = 'dynect'
|
|
|
|
__backend_status__ = 'untested'
|
|
|
|
def __init__(self, target):
|
|
super().__init__(target)
|
|
|
|
self.customer_name = self.options.get('customer_name')
|
|
self.username = self.options.get('username')
|
|
self.password = self.options.get('password')
|
|
self.contact_nickname = self.options.get('contact_nickname', None)
|
|
self.tsig_key_name = self.options.get('tsig_key_name', None)
|
|
|
|
for m in self.masters:
|
|
if m.port != 53:
|
|
raise exceptions.ConfigurationError(
|
|
"DynECT only supports mDNS instances on port 53")
|
|
|
|
def get_client(self):
|
|
return DynClient(
|
|
customer_name=self.customer_name,
|
|
user_name=self.username,
|
|
password=self.password,
|
|
timeout=CONF[CFG_GROUP_NAME].timeout,
|
|
timings=CONF[CFG_GROUP_NAME].timings)
|
|
|
|
def create_zone(self, context, zone):
|
|
LOG.info(
|
|
'Creating zone %(d_id)s / %(d_name)s',
|
|
{
|
|
'd_id': zone['id'],
|
|
'd_name': zone['name']
|
|
}
|
|
)
|
|
|
|
url = '/Secondary/%s' % zone['name'].rstrip('.')
|
|
data = {
|
|
'masters': [m.host for m in self.masters]
|
|
}
|
|
|
|
if self.contact_nickname is not None:
|
|
data['contact_nickname'] = self.contact_nickname
|
|
|
|
if self.tsig_key_name is not None:
|
|
data['tsig_key_name'] = self.tsig_key_name
|
|
|
|
client = self.get_client()
|
|
|
|
try:
|
|
client.post(url, data=data)
|
|
except DynClientError as e:
|
|
for emsg in e.msgs:
|
|
if emsg['ERR_CD'] == 'TARGET_EXISTS':
|
|
LOG.info(
|
|
'Zone already exists, updating existing zone '
|
|
'instead %s', zone['name']
|
|
)
|
|
client.put(url, data=data)
|
|
break
|
|
else:
|
|
raise
|
|
|
|
client.put(url, data={'activate': True})
|
|
client.logout()
|
|
|
|
def delete_zone(self, context, zone, zone_params=None):
|
|
LOG.info(
|
|
'Deleting zone %(d_id)s / %(d_name)s', {
|
|
'd_id': zone['id'],
|
|
'd_name': zone['name']
|
|
}
|
|
)
|
|
url = '/Zone/%s' % zone['name'].rstrip('.')
|
|
client = self.get_client()
|
|
try:
|
|
client.delete(url)
|
|
except DynClientError as e:
|
|
if e.http_status == 404:
|
|
LOG.warning(
|
|
'Attempt to delete %(d_id)s / %(d_name)s caused 404, '
|
|
'ignoring.', {
|
|
'd_id': zone['id'],
|
|
'd_name': zone['name']
|
|
}
|
|
)
|
|
pass
|
|
else:
|
|
raise
|
|
client.logout()
|