436 lines
16 KiB
Python
Raw Normal View History

2013-09-28 17:17:12 +02:00
"""
Misc. DNS related code: query, dynamic update, etc.
Usually, higher level code wants to call the add/update/delete functions.
"""
import os
# time to wait for dns name resolving [s]
RESOLVER_TIMEOUT = float(os.environ.get('DNS_RESOLVER_TIMEOUT', '10.0'))
# time to wait for dns name updating [s]
UPDATE_TIMEOUT = float(os.environ.get('DNS_UPDATE_TIMEOUT', '20.0'))
# time after we retry to reach a previously unreachable ns [s]
UNAVAILABLE_RETRY = 120.0
import time
from datetime import timedelta
from collections import namedtuple
import logging
logger = logging.getLogger(__name__)
2013-11-27 07:14:39 +01:00
import socket
import random
import struct
2013-11-27 07:14:39 +01:00
import dns.inet
import dns.message
import dns.name
import dns.resolver
import dns.query
import dns.update
import dns.tsig
import dns.tsigkeyring
import dns.exception
from django.utils.timezone import now
class FQDN(namedtuple('FQDN', ['host', 'domain'])):
"""
named tuple to represent a fully qualified domain name:
* a host in a zone/domain
* just a zone/domain (give host=None when creating)
use this instead of str so that the information is not lost
what the host part is vs. what the domain part is.
e.g. foo.bar.example.org could be a host foo in domain bar.example.org
or a host foo.bar in domain example.org.
"""
def __str__(self):
"""
when transforming this into a str, just give the fqdn
"""
if self.host:
return self.host + '.' + self.domain
else:
return self.domain
Timeout = dns.resolver.Timeout
NoNameservers = dns.resolver.NoNameservers
DNSException = dns.exception.DNSException
class SameIpError(ValueError):
"""
raised if an IP address is already present in DNS and and update was
requested, but is not needed.
"""
class DnsUpdateError(ValueError):
"""
raised if DNS update return code is not NOERROR
"""
class NameServerNotAvailable(Exception):
"""
raised if some nameserver was flagged as not available,
but we tried using it.
"""
def check_ip(ipaddr, keys=('ipv4', 'ipv6')):
"""
Check if a string is a valid ip address and also
determine the kind of the address (address family).
Return first key for v4, second key for v6.
:param ipaddr: ip address, v4 or v6, str
:param keys: 2-tuple (v4key, v6key)
:return: v4key or v6key
:raises: ValueError if the ip is invalid
"""
af = dns.inet.af_for_address(ipaddr)
return keys[af == dns.inet.AF_INET6]
def check_domain(domain):
fqdn = FQDN(host="connectivity-test", domain=domain)
from .models import Domain
d = Domain.objects.get(name=domain)
# temporarily set domain to available to allow add/update/deletes
domain_available_state = d.available
d.available = True
d.save()
try:
# add host connectivity-test.<domain> with a random IP. See add()
add(fqdn, socket.inet_ntoa(struct.pack('>I', random.randint(1, 0xffffffff))))
except (dns.exception.DNSException, DnsUpdateError) as e:
raise NameServerNotAvailable(str(e))
finally:
# reset domain available
d.available = domain_available_state
d.save()
def add(fqdn, ipaddr, ttl=60):
"""
intelligent dns adder - first does a lookup on the master server to find
the current ip and only sends an 'add' if there is no such entry.
otherwise send an 'upd' if the if we have a different ip.
:param fqdn: fully qualified domain name (FQDN)
:param ipaddr: new ip address
:param ttl: time to live, default 60s (int)
:raises: SameIpError if new and old IP is the same
:raises: ValueError if ipaddr is no valid ip address string
"""
assert isinstance(fqdn, FQDN)
rdtype = check_ip(ipaddr, keys=('A', 'AAAA'))
try:
current_ipaddr = query_ns(fqdn, rdtype)
# check if ip really changed
ok = ipaddr != current_ipaddr
action = 'upd'
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# no dns entry yet, ok
ok = True
action = 'add'
if ok:
# only send an add/update if the ip really changed as the update
# causes write I/O on the nameserver and also traffic to the
# dns slaves (they get a notify if we update the zone).
update_ns(fqdn, rdtype, ipaddr, action=action, ttl=ttl)
else:
raise SameIpError
def delete(fqdn, rdtype=None):
"""
intelligent dns deleter - first does a lookup on the master server to find
out whether there is a dns entry and only send a 'del' if there is an entry.
:param fqdn: fully qualified domain name (FQDN)
:param rdtype: 'A', 'AAAA' or None (deletes 'A' and 'AAAA')
"""
assert isinstance(fqdn, FQDN)
if rdtype is not None:
assert rdtype in ['A', 'AAAA', ]
rdtypes = [rdtype, ]
else:
rdtypes = ['A', 'AAAA']
for rdtype in rdtypes:
try:
# check if we have a DNS entry
query_ns(fqdn, rdtype)
# there is a dns entry
ok = True
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# no dns entry, it is already deleted
ok = False
except (dns.resolver.Timeout, dns.resolver.NoNameservers) as e: # socket.error also?
# maybe could be caused by secondary DNS Timeout and master still ok?
# assume the delete is OK...
ok = True
if ok:
# send a del
update_ns(fqdn, rdtype, action='del')
def update(fqdn, ipaddr, ttl=60):
2013-09-29 02:00:15 +02:00
"""
intelligent dns updater - first does a lookup on the master server to find
the current ip and only sends a dynamic update if we have a different ip.
:param fqdn: fully qualified domain name (FQDN)
2013-09-29 02:00:15 +02:00
:param ipaddr: new ip address
:param ttl: time to live, default 60s (int)
:raises: SameIpError if new and old IP is the same
:raises: ValueError if ipaddr is no valid ip address string
2013-09-29 02:00:15 +02:00
"""
assert isinstance(fqdn, FQDN)
rdtype = check_ip(ipaddr, keys=('A', 'AAAA'))
try:
current_ipaddr = query_ns(fqdn, rdtype)
# check if ip really changed
ok = ipaddr != current_ipaddr
except (dns.resolver.NXDOMAIN, dns.resolver.NoAnswer):
# no dns entry yet, ok
ok = True
except (dns.resolver.Timeout, dns.resolver.NoNameservers) as e: # socket.error also?
# maybe could be caused by secondary DNS Timeout and master still ok?
# assume the update is OK...
ok = True
if ok:
# only send an update if the ip really changed as the update
# causes write I/O on the nameserver and also traffic to the
# dns slaves (they get a notify if we update the zone).
update_ns(fqdn, rdtype, ipaddr, action='upd', ttl=ttl)
else:
raise SameIpError
def query_ns(fqdn, rdtype, prefer_primary=False):
"""
query a dns name from our DNS server(s)
:param fqdn: fqdn to query the name server for
:type fqdn: dnstools.FQDN
:param rdtype: the query type
:type rdtype: int or str
:param prefer_primary: whether we rather want to query the primary first
2013-12-15 18:44:33 +01:00
:return: IP (as str)
:raises: see dns.resolver.Resolver.query
"""
assert isinstance(fqdn, FQDN)
nameserver, nameserver2, origin = get_ns_info(fqdn)[0:3]
resolver = dns.resolver.Resolver(configure=False)
# we do not configure it from resolv.conf, but patch in the values we
# want into the documented attributes:
resolver.nameservers = [nameserver, ]
if nameserver2:
pos = 1 if prefer_primary else 0
resolver.nameservers.insert(pos, nameserver2)
# we must put the root zone into the search list, so that if a fqdn without "."
# at the end comes in, it will append "." (and not the service server's domain).
resolver.search = [dns.name.root, ]
resolver.lifetime = RESOLVER_TIMEOUT
# as we query directly the (authoritative) master dns, we do not desire
# recursion. But: RD (recursion desired) is the internal default for flags
# (used if flags = None is given). Thus, we explicitly give flags (all off):
resolver.flags = 0
try:
answer = resolver.query(str(fqdn), rdtype)
ip = str(list(answer)[0])
logger.debug("query: %s answer: %s" % (fqdn, ip))
return ip
except (dns.resolver.Timeout, dns.resolver.NoNameservers, dns.message.UnknownTSIGKey) as e: # socket.error also?
logger.warning("error when querying for name '%s' in zone '%s' with rdtype '%s' [%s]." % (
fqdn.host, origin, rdtype, str(e)))
set_ns_availability(origin, False)
raise
2013-11-27 07:14:39 +01:00
def rev_lookup(ipaddr):
"""
do a normal reverse DNS lookup, IP to name
note: this call may be slow, especially if there is no reverse dns entry
2013-11-27 07:14:39 +01:00
:param ipaddr: ip address (str)
:return: hostname (or empty string if lookup failed)
2013-11-27 07:14:39 +01:00
"""
name = ''
if ipaddr:
try:
name = socket.gethostbyaddr(ipaddr)[0]
except socket.error:
pass
return name
2013-11-27 07:14:39 +01:00
def get_ns_info(fqdn):
"""
Get the master nameserver for fqdn, the key secret needed to update the zone and the key algorithm used.
:param fqdn: the fully qualified hostname we are dealing with (str)
:return: master nameserver, origin, domain, update keyname, update secret, update algo
:raises: NameServerNotAvailable if ns was flagged unavailable in the db
"""
assert isinstance(fqdn, FQDN)
from .models import Domain
try:
# first we check if we have an entry for the fqdn
# single-host update secret use case
# XXX we need 2 DB accesses for the usual case just to support this rare case
domain = str(fqdn)
d = Domain.objects.get(name=domain)
except Domain.DoesNotExist:
# now check the base zone, the usual case
# zone update secret use case
domain = fqdn.domain
d = Domain.objects.get(name=domain)
if not d.available:
if d.last_update + timedelta(seconds=UNAVAILABLE_RETRY) > now():
# if there are troubles with a nameserver, we set available=False
# and stop trying working with that nameserver for a while
raise NameServerNotAvailable("nameserver for domain %s at IP %s was flagged unavailable" % (
domain, d.nameserver_ip, ))
else:
# retry timeout is over, set it available again
set_ns_availability(domain, True)
algorithm = getattr(dns.tsig, d.nameserver_update_algorithm)
2014-11-15 22:45:57 +01:00
return (d.nameserver_ip, d.nameserver2_ip, fqdn.domain, domain, fqdn.host, domain,
d.nameserver_update_secret, algorithm)
def update_ns(fqdn, rdtype='A', ipaddr=None, action='upd', ttl=60):
"""
2013-12-15 17:29:55 +01:00
update the master server
:param fqdn: the fully qualified domain name to update (FQDN)
2013-09-28 15:36:27 +02:00
:param rdtype: the record type (default: 'A') (str)
:param ipaddr: ip address (v4 or v6), if needed (str)
:param action: 'add', 'del' or 'upd'
2013-09-28 15:36:27 +02:00
:param ttl: time to live for the added/updated resource, default 60s (int)
:return: dns response
2013-12-15 18:44:33 +01:00
:raises: DnsUpdateError, Timeout
"""
assert isinstance(fqdn, FQDN)
assert action in ['add', 'del', 'upd', ]
nameserver, nameserver2, origin, domain, name, keyname, key, algo = get_ns_info(fqdn)
upd = dns.update.Update(origin,
keyring=dns.tsigkeyring.from_text({keyname: key}),
keyalgorithm=algo)
if action == 'add':
assert ipaddr is not None
upd.add(name, ttl, rdtype, ipaddr)
elif action == 'del':
upd.delete(name, rdtype)
elif action == 'upd':
assert ipaddr is not None
upd.replace(name, ttl, rdtype, ipaddr)
logger.debug("performing %s for name %s and origin %s with rdtype %s and ipaddr %s" % (
action, name, origin, rdtype, ipaddr))
try:
response = dns.query.tcp(upd, nameserver, timeout=UPDATE_TIMEOUT)
rcode = response.rcode()
if rcode != dns.rcode.NOERROR:
rcode_text = dns.rcode.to_text(rcode)
logger.warning("DNS error [%s] performing %s for name %s and origin %s with rdtype %s and ipaddr %s" % (
rcode_text, action, name, origin, rdtype, ipaddr))
raise DnsUpdateError(rcode_text)
return response
# TODO simplify exception handling when https://github.com/rthalley/dnspython/pull/85 is merged/released
except socket.error as e:
logger.error("socket.error [%s] - zone: %s" % (str(e), origin, ))
set_ns_availability(domain, False)
raise DnsUpdateError("SocketError %d" % e.errno)
except EOFError as e:
logger.error("EOFError [%s] - zone: %s" % (str(e), origin, ))
set_ns_availability(domain, False)
raise DnsUpdateError("EOFError")
except dns.exception.Timeout:
logger.warning("timeout when performing %s for name %s and origin %s with rdtype %s and ipaddr %s" % (
action, name, origin, rdtype, ipaddr))
set_ns_availability(domain, False)
2015-12-28 12:50:38 +01:00
raise DnsUpdateError("Timeout")
except dns.tsig.PeerBadSignature:
logger.error("PeerBadSignature - shared secret mismatch? zone: %s" % (origin, ))
set_ns_availability(domain, False)
raise DnsUpdateError("PeerBadSignature")
except dns.tsig.PeerBadKey:
logger.error("PeerBadKey - shared secret mismatch? zone: %s" % (origin, ))
set_ns_availability(domain, False)
raise DnsUpdateError("PeerBadKey")
2014-09-08 02:44:12 +02:00
except dns.tsig.PeerBadTime:
logger.error("PeerBadTime - DNS server did not like the time we sent. zone: %s" % (origin, ))
set_ns_availability(domain, False)
raise DnsUpdateError("PeerBadTime")
except dns.message.UnknownTSIGKey as e:
logger.error("UnknownTSIGKey [%s] - zone: %s" % (str(e), origin, ))
set_ns_availability(domain, False)
raise DnsUpdateError("UnknownTSIGKey")
def set_ns_availability(domain, available):
"""
Set availability of the master nameserver for <domain>.
As each Timeout takes quite a while, we want to avoid it.
:param domain: domain object or string, may end with "."
:param available: True/False for availability of ns
"""
from .models import Domain
domain = str(domain).rstrip('.')
d = Domain.objects.get(name=domain)
d.available = available
d.save()
if available:
logger.info("set zone '%s' to available" % domain)
else:
logger.warning("set zone '%s' to unavailable" % domain)
def put_ip_into_session(session, ipaddr, kind=None, max_age=0,
save=False):
"""
put an IP address into the session, including a timestamp,
so we know how fresh it is.
:param session: the session object
:param ipaddr: ip address (can be v4 or v6, str)
:param kind: 'ipv4' or 'ipv6' or None to autodetect
:param max_age: maximum age of info, if older we refresh the timestamp
2013-12-15 18:44:33 +01:00
:param save: save the session immediately, if it was changed
"""
if kind is None:
kind = check_ip(ipaddr)
# we try to avoid modifying the session if not necessary...
if session.get(kind) != ipaddr:
# we have a new ip, remember it, with timestamp
session[kind + '_timestamp'] = int(time.time())
session[kind] = ipaddr
session[kind + '_rdns'] = rev_lookup(ipaddr) # may be slow
else:
old_timestamp = session.get(kind + '_timestamp')
if not max_age or old_timestamp is None or old_timestamp + max_age < int(time.time()):
# keep it fresh (to avoid that it gets killed and triggers detection)
session[kind + '_timestamp'] = int(time.time())
if save and session.modified:
session.save()