designate/designate/dnsutils.py
Erik Olof Gunnar Andersson 68fc28527a pyupgrade changes for Python3.8+
Result of running

$ pyupgrade --py38-plus $(git ls-files | grep ".py$")

This was inspired by Nova [1] and Octavia [2]

Fixed PEP8 errors introduced by pyupgrade by running:

$ autopep8 --select=E127,E128,E501 --max-line-length 79 -r \
  --in-place designate

and manual updates.

[1]: https://review.opendev.org/c/openstack/nova/+/896986
[2]: https://review.opendev.org/c/openstack/octavia/+/899263

Change-Id: Idfa757d7ba238012db116fdb3e98cc7c5ff4b169
2023-11-03 11:19:07 +00:00

321 lines
9.2 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 random
import socket
import threading
import time
import dns.exception
import dns.message
import dns.opcode
import dns.query
import dns.rdatatype
import dns.zone
import eventlet
from oslo_log import log as logging
from oslo_serialization import base64
import designate.conf
from designate import context
from designate import exceptions
from designate import objects
CONF = designate.conf.CONF
LOG = logging.getLogger(__name__)
class TsigKeyring(dict):
"""Implements the DNSPython KeyRing API, backed by the Designate DB"""
def __init__(self, storage):
super().__init__()
self.storage = storage
def __getitem__(self, key):
return self.get(key)
def get(self, key, default=None):
try:
name = key.to_text(True)
if isinstance(name, bytes):
name = name.decode('utf-8')
criterion = {'name': name}
tsigkey = self.storage.find_tsigkey(
context.get_current(), criterion
)
return base64.decode_as_bytes(tsigkey.secret)
except exceptions.TsigKeyNotFound:
return default
class ZoneLock:
"""A Lock across all zones that enforces a rate limit on NOTIFYs"""
def __init__(self, delay):
self.lock = threading.Lock()
self.data = {}
self.delay = delay
def acquire(self, zone):
with self.lock:
# If no one holds the lock for the zone, grant it
if zone not in self.data:
self.data[zone] = time.monotonic()
return True
# Otherwise, get the time that it was locked
locktime = self.data[zone]
now = time.monotonic()
period = now - locktime
# If it has been locked for longer than the allowed period
# give the lock to the new requester
if period > self.delay:
self.data[zone] = now
return True
LOG.debug(
'Lock for %(zone)s can\'t be released for %(period)s seconds',
{
'zone': zone,
'period': str(self.delay - period)
}
)
# Don't grant the lock for the zone
return False
def release(self, zone):
# Release the lock
with self.lock:
try:
self.data.pop(zone)
except KeyError:
pass
def from_dnspython_zone(dnspython_zone):
# dnspython never builds a zone with more than one SOA, even if we give
# it a zonefile that contains more than one
soa = dnspython_zone.get_rdataset(dnspython_zone.origin, 'SOA')
if soa is None:
raise exceptions.BadRequest('An SOA record is required')
if soa.ttl == 0:
soa.ttl = CONF['service:central'].min_ttl
email = soa[0].rname.to_text(omit_final_dot=True)
if isinstance(email, bytes):
email = email.decode('utf-8')
email = email.replace('.', '@', 1)
name = dnspython_zone.origin.to_text()
if isinstance(name, bytes):
name = name.decode('utf-8')
values = {
'name': name,
'email': email,
'ttl': soa.ttl,
'serial': soa[0].serial,
'retry': soa[0].retry,
'expire': soa[0].expire
}
zone = objects.Zone(**values)
rrsets = dnspyrecords_to_recordsetlist(dnspython_zone.nodes)
zone.recordsets = rrsets
return zone
def dnspyrecords_to_recordsetlist(dnspython_records):
rrsets = objects.RecordSetList()
for rname in dnspython_records.keys():
for rdataset in dnspython_records[rname]:
rrset = dnspythonrecord_to_recordset(rname, rdataset)
if rrset is None:
continue
rrsets.append(rrset)
return rrsets
def dnspythonrecord_to_recordset(rname, rdataset):
record_type = dns.rdatatype.to_text(rdataset.rdtype)
name = rname.to_text()
if isinstance(name, bytes):
name = name.decode('utf-8')
# Create the other recordsets
values = {
'name': name,
'type': record_type
}
if rdataset.ttl != 0:
values['ttl'] = rdataset.ttl
rrset = objects.RecordSet(**values)
rrset.records = objects.RecordList()
for rdata in rdataset:
rr = objects.Record(data=rdata.to_text())
rrset.records.append(rr)
return rrset
def xfr_timeout():
return CONF['service:worker'].xfr_timeout
def do_axfr(zone_name, servers, source=None):
"""
Requests an AXFR for a given zone name and process the response
:returns: Zone instance from dnspython
"""
random.shuffle(servers)
xfr = None
for srv in servers:
for address in get_ip_addresses(srv['host']):
to = eventlet.Timeout(xfr_timeout())
log_info = {'name': zone_name, 'host': srv, 'address': address}
try:
LOG.info(
'Doing AXFR for %(name)s from %(host)s %(address)s',
log_info
)
xfr = dns.query.xfr(
address, zone_name, relativize=False, timeout=1,
port=srv['port'], source=source
)
raw_zone = dns.zone.from_xfr(xfr, relativize=False)
LOG.debug('AXFR Successful for %s', raw_zone.origin.to_text())
return raw_zone
except eventlet.Timeout as t:
if t == to:
LOG.error('AXFR timed out for %(name)s from %(host)s',
log_info)
continue
except dns.exception.FormError:
LOG.error('Zone %(name)s is not present on %(host)s.'
'Trying next server.', log_info)
except OSError:
LOG.error('Connection error when doing AXFR for %(name)s from '
'%(host)s', log_info)
except Exception:
LOG.exception('Problem doing AXFR %(name)s from %(host)s. '
'Trying next server.', log_info)
finally:
to.cancel()
raise exceptions.XFRFailure(
'XFR failed for %(name)s. No servers in %(servers)s was reached.' %
{'name': zone_name, 'servers': servers}
)
def prepare_dns_message(zone_name, rdatatype, opcode):
"""
Create a dns message using dnspython
"""
dns_message = dns.message.make_query(zone_name, rdatatype)
dns_message.set_opcode(opcode)
return dns_message
def notify(zone_name, host, port=53, timeout=10):
"""
Create a NOTIFY message and send it
"""
dns_message = prepare_dns_message(
zone_name, rdatatype=dns.rdatatype.SOA, opcode=dns.opcode.NOTIFY
)
return send_dns_message(dns_message, host, port=port, timeout=timeout)
def soa_query(zone_name, host, port=53, timeout=10):
"""
Create a SOA Query message and send it
"""
dns_message = prepare_dns_message(
zone_name, rdatatype=dns.rdatatype.SOA, opcode=dns.opcode.QUERY
)
return send_dns_message(dns_message, host, port=port, timeout=timeout)
def use_all_tcp():
return CONF['service:worker'].all_tcp
def send_dns_message(dns_message, host, port=53, timeout=10):
"""
Send the dns message and return the response
:return: dns.Message of the response to the dns query
"""
ip_address = get_ip_address(host)
# This can raise some exceptions, but we'll catch them elsewhere
if not use_all_tcp():
return dns.query.udp(
dns_message, ip_address, port=port, timeout=timeout)
return dns.query.tcp(
dns_message, ip_address, port=port, timeout=timeout)
def get_serial(zone_name, host, port=53):
"""
Possibly raises dns.exception.Timeout or dns.query.BadResponse.
Possibly returns 0 if, e.g., the answer section is empty.
"""
resp = soa_query(zone_name, host, port=port)
if not resp.answer:
return 0
rdataset = resp.answer[0].to_rdataset()
if not rdataset:
return 0
return rdataset[0].serial
def get_ip_address(ip_address_or_hostname):
"""
Provide an ip or hostname and return a valid ip4 or ipv6 address.
:return: ip address
"""
addresses = get_ip_addresses(ip_address_or_hostname)
if not addresses:
return None
return addresses[0]
def get_ip_addresses(ip_address_or_hostname):
"""
Provide an ip or hostname and return all valid ip4 or ipv6 addresses.
:return: ip addresses
"""
addresses = []
for res in socket.getaddrinfo(ip_address_or_hostname, 0):
addresses.append(res[4][0])
return list(set(addresses))