# Copyright 2012 Managed I.T.
# Copyright 2013 - 2014 Hewlett-Packard Development Company, L.P.
#
# Author: Kiall Mac Innes <kiall@managedit.ie>
#
# 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.
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import TimeoutError
import copy
import random
from random import SystemRandom
import re
import string
from dns import exception as dnsexception
from dns import zone as dnszone
from oslo_log import log as logging
import oslo_messaging as messaging
from oslo_utils import timeutils
from designate.common import constants
from designate.common.decorators import lock
from designate.common.decorators import notification
from designate.common.decorators import rpc
import designate.conf
from designate import coordination
from designate import dnsutils
from designate import exceptions
from designate import network_api
from designate import objects
from designate import policy
from designate import quota
from designate import scheduler
from designate import service
from designate import storage
from designate.storage import transaction
from designate.storage import transaction_shallow_copy
from designate import utils
from designate.worker import rpcapi as worker_rpcapi
CONF = designate.conf.CONF
LOG = logging.getLogger(__name__)
[docs]
class Service(service.RPCService):
RPC_API_VERSION = '6.10'
target = messaging.Target(version=RPC_API_VERSION)
def __init__(self):
self.zone_lock_local = lock.ZoneLockLocal()
self.notification_thread_local = notification.NotificationThreadLocal()
self._scheduler = None
self._storage = None
self._quota = None
self._blacklist_executor = ThreadPoolExecutor(max_workers=1)
super().__init__(
self.service_name, CONF['service:central'].topic,
threads=CONF['service:central'].threads,
)
self.coordination = coordination.Coordination(
self.service_name, self.tg, grouping_enabled=False
)
self.network_api = network_api.get_network_api(CONF.network_api)
@property
def scheduler(self):
if not self._scheduler:
# Get a scheduler instance
self._scheduler = scheduler.get_scheduler(storage=self.storage)
return self._scheduler
@property
def quota(self):
if not self._quota:
# Get a quota manager instance
self._quota = quota.get_quota()
return self._quota
@property
def storage(self):
if not self._storage:
self._storage = storage.get_storage()
return self._storage
@property
def service_name(self):
return 'central'
[docs]
def start(self):
if (CONF['service:central'].managed_resource_tenant_id ==
"00000000-0000-0000-0000-000000000000"):
LOG.warning("Managed Resource Tenant ID is not properly "
"configured")
super().start()
self.coordination.start()
[docs]
def stop(self, graceful=True):
self.coordination.stop()
super().stop(graceful)
@property
def worker_api(self):
return worker_rpcapi.WorkerAPI.get_instance()
def _is_valid_zone_name(self, context, zone_name):
# Validate zone name length
if zone_name is None:
raise exceptions.InvalidObject
if len(zone_name) > CONF['service:central'].max_zone_name_len:
raise exceptions.InvalidZoneName('Name too long')
# Break the zone name up into its component labels
zone_labels = zone_name.strip('.').split('.')
# We need more than 1 label.
if len(zone_labels) <= 1:
raise exceptions.InvalidZoneName('More than one label is '
'required')
tlds = self.storage.find_tlds(context)
if tlds:
LOG.debug("Checking if %s has a valid TLD", zone_name)
allowed = False
for i in range(-len(zone_labels), 0):
last_i_labels = zone_labels[i:]
LOG.debug("Checking %s against the TLD list", last_i_labels)
if ".".join(last_i_labels) in tlds:
allowed = True
break
if not allowed:
raise exceptions.InvalidZoneName('Invalid TLD')
# Now check that the zone name is not the same as a TLD
try:
stripped_zone_name = zone_name.rstrip('.').lower()
self.storage.find_tld(
context,
{'name': stripped_zone_name})
except exceptions.TldNotFound:
LOG.debug("%s has a valid TLD", zone_name)
else:
raise exceptions.InvalidZoneName(
'Zone name cannot be the same as a TLD')
# Check zone name blacklist
if self._is_blacklisted_zone_name(context, zone_name):
# Some users are allowed bypass the blacklist.. Is this one?
if not policy.check('use_blacklisted_zone', context,
do_raise=False):
raise exceptions.InvalidZoneName('Blacklisted zone name')
return True
def _is_valid_recordset_name(self, context, zone, recordset_name):
if recordset_name is None:
raise exceptions.InvalidObject
if not recordset_name.endswith('.'):
raise ValueError('Please supply a FQDN')
# Validate record name length
max_len = CONF['service:central'].max_recordset_name_len
if len(recordset_name) > max_len:
raise exceptions.InvalidRecordSetName('Name too long')
# RecordSets must be contained in the parent zone
if (recordset_name != zone['name'] and
not recordset_name.endswith("." + zone['name'])):
raise exceptions.InvalidRecordSetLocation(
'RecordSet is not contained within it\'s parent zone')
def _is_valid_recordset_placement(self, context, zone, recordset_name,
recordset_type, recordset_id=None):
# CNAME's must not be created at the zone apex.
if recordset_type == 'CNAME' and recordset_name == zone.name:
raise exceptions.InvalidRecordSetLocation(
'CNAME recordsets may not be created at the zone apex')
# CNAME's must not share a name with other recordsets
criterion = {
'zone_id': zone.id,
'name': recordset_name,
}
if recordset_type != 'CNAME':
criterion['type'] = 'CNAME'
recordsets = self.storage.find_recordsets(context, criterion)
if ((len(recordsets) == 1 and recordsets[0].id != recordset_id) or
len(recordsets) > 1):
raise exceptions.InvalidRecordSetLocation(
'CNAME recordsets may not share a name with any other records')
return True
def _is_valid_recordset_placement_subzone(self, context, zone,
recordset_name,
criterion=None):
"""
Check that the placement of the requested rrset belongs to any of the
zones subzones..
"""
LOG.debug("Checking if %s belongs in any of %s subzones",
recordset_name, zone.name)
criterion = criterion or {}
context = context.elevated(all_tenants=True)
if zone.name == recordset_name:
return
child_zones = self.storage.find_zones(
context, {"parent_zone_id": zone.id})
for child_zone in child_zones:
try:
self._is_valid_recordset_name(
context, child_zone, recordset_name)
except Exception:
continue
else:
msg = (
'RecordSet belongs in a child zone: {}'
.format(child_zone['name'])
)
raise exceptions.InvalidRecordSetLocation(msg)
def _is_valid_recordset_records(self, recordset):
"""
Check to make sure that the records in the recordset
follow the rules, and won't blow up on the nameserver.
"""
try:
recordset.records
except (AttributeError, exceptions.RelationNotLoaded):
pass
else:
if len(recordset.records) > 1 and recordset.type == 'CNAME':
raise exceptions.BadRequest(
'CNAME recordsets may not have more than 1 record'
)
def _is_blacklisted_zone_name(self, context, zone_name):
"""
Ensures the provided zone_name is not blacklisted.
"""
blacklists = self.storage.find_blacklists(context)
def _check_pattern(pattern, name):
return bool(re.search(pattern, name))
for blacklist in blacklists:
future = self._blacklist_executor.submit(_check_pattern,
blacklist.pattern,
zone_name)
try:
if future.result(timeout=0.02):
return True
except TimeoutError:
LOG.critical(
'Blacklist regex (%(pattern)s) took too long to '
'evaluate against zone name (%(zone_name)s)',
{
'pattern': blacklist.pattern,
'zone_name': zone_name
})
return True
return False
def _is_subzone(self, context, zone_name, pool_id):
"""
Ensures the provided zone_name is the subzone
of an existing zone (checks across all tenants)
"""
context = context.elevated(all_tenants=True)
# Break the name up into it's component labels
labels = zone_name.split(".")
criterion = {"pool_id": pool_id}
i = 1
# Starting with label #2, search for matching zone's in the database
while (i < len(labels)):
name = '.'.join(labels[i:])
criterion["name"] = name
try:
zone = self.storage.find_zone(context, criterion)
except exceptions.ZoneNotFound:
i += 1
else:
return zone
return False
def _is_superzone(self, context, zone_name, pool_id):
"""
Ensures the provided zone_name is the parent zone
of an existing subzone (checks across all tenants)
"""
context = context.elevated(all_tenants=True)
# Create wildcard term to catch all subzones
search_term = "%%.%(name)s" % {"name": zone_name}
criterion = {'name': search_term, "pool_id": pool_id}
subzones = self.storage.find_zones(context, criterion)
return subzones
def _is_valid_ttl(self, context, ttl):
if ttl is None:
return
min_ttl = CONF['service:central'].min_ttl
if min_ttl is not None and ttl < int(min_ttl):
try:
policy.check('use_low_ttl', context)
except exceptions.Forbidden:
raise exceptions.InvalidTTL('TTL is below the minimum: %s'
% min_ttl)
def _is_valid_project_id(self, project_id):
if project_id is None:
raise exceptions.MissingProjectID(
"A project ID must be specified when not using a project "
"scoped token.")
# SOA Recordset Methods
@staticmethod
def _build_soa_record(zone, ns_records):
address = zone['email']
atsign = address.index('@')
soa_address = (address[0:atsign].replace('.', '\\.') + '.' +
address[atsign + 1:])
return '%s %s. %d %d %d %d %d' % (
ns_records[0]['hostname'],
soa_address,
zone['serial'],
zone['refresh'],
zone['retry'],
zone['expire'],
zone['minimum']
)
def _create_soa(self, context, zone):
pool_ns_records = self._get_pool_ns_records(context, zone.pool_id)
records = objects.RecordList(objects=[
objects.Record(
data=self._build_soa_record(zone, pool_ns_records),
managed=True
)
])
return self._create_recordset_in_storage(
context, zone,
objects.RecordSet(
name=zone['name'],
type='SOA',
records=records
), increment_serial=False
)[0]
def _update_soa(self, context, zone):
# NOTE: We should not be updating SOA records when a zone is SECONDARY.
if zone.type == constants.ZONE_SECONDARY:
return
# Get the pool for it's list of ns_records
pool_ns_records = self._get_pool_ns_records(context, zone.pool_id)
soa = self.find_recordset(
context, criterion={
'zone_id': zone['id'],
'type': 'SOA'
}
)
soa.records[0].data = self._build_soa_record(zone, pool_ns_records)
self._update_recordset_in_storage(
context, zone, soa, increment_serial=False
)
# NS Recordset Methods
def _create_ns(self, context, zone, ns_records):
# NOTE: We should not be creating NS records when a zone is SECONDARY.
if zone.type != 'PRIMARY':
return
# Create an NS record for each server
recordlist = objects.RecordList(objects=[
objects.Record(data=r, managed=True) for r in ns_records])
values = {
'name': zone['name'],
'type': 'NS',
'records': recordlist
}
ns, zone = self._create_recordset_in_storage(
context, zone, objects.RecordSet(**values),
increment_serial=False
)
return ns
def _add_ns(self, context, zone, ns_record):
# Get NS recordset
# If the zone doesn't have an NS recordset yet, create one
try:
recordset = self.find_recordset(
context,
criterion={
'zone_id': zone['id'],
'name': zone['name'],
'type': 'NS'
}
)
except exceptions.RecordSetNotFound:
self._create_ns(context, zone, [ns_record])
return
# Add new record to recordset based on the new nameserver
recordset.records.append(
objects.Record(data=ns_record, managed=True)
)
self._update_recordset_in_storage(context, zone, recordset,
set_delayed_notify=True)
def _delete_ns(self, context, zone, ns_record):
recordset = self.find_recordset(
context,
criterion={
'zone_id': zone['id'],
'name': zone['name'],
'type': 'NS'
}
)
for record in list(recordset.records):
if record.data == ns_record:
recordset.records.remove(record)
self._update_recordset_in_storage(context, zone, recordset,
set_delayed_notify=True)
# Quota Enforcement Methods
def _enforce_zone_quota(self, context, tenant_id):
criterion = {'tenant_id': tenant_id}
count = self.storage.count_zones(context, criterion)
# Check if adding one more zone would exceed the quota
self.quota.limit_check(context, tenant_id, zones=count + 1)
def _enforce_recordset_quota(self, context, zone):
# Ensure the recordsets per zone quota is OK
criterion = {'zone_id': zone.id}
count = self.storage.count_recordsets(context, criterion)
# Check if adding one more recordset would exceed the quota
self.quota.limit_check(
context, zone.tenant_id, zone_recordsets=count + 1)
def _enforce_record_quota(self, context, zone, recordset):
# Quotas don't apply to managed records.
if recordset.managed:
return
# Ensure the records per zone quota is OK
zone_criterion = {