Skip to content

Instantly share code, notes, and snippets.

@mgedmin
Created October 6, 2017 14:14
Show Gist options
  • Select an option

  • Save mgedmin/37149fa66b537a2d158f9f5c49096701 to your computer and use it in GitHub Desktop.

Select an option

Save mgedmin/37149fa66b537a2d158f9f5c49096701 to your computer and use it in GitHub Desktop.
Example of using Fabric to manage DNS zone files for BIND
"""
example.com DNS deployments
"""
import time
import collections
from fabric.api import env, task, settings, local, put, run, abort, warn, quiet
from fabric.contrib.files import contains
# https://pypi.python.org/pypi/pov-fabric-helpers, but you can probably get rid of them and use Fabric directly
from pov_fabric import (
install_missing_packages, changelog, changelog_append, run_and_changelog,
Instance as BaseInstance, get_instance, asbool,
)
from easyzone import easyzone # pip install dnspython easyzone
env.use_ssh_config = True
env.forward_agent = True
class Instance(BaseInstance):
def __init__(self, name, host, domain):
super(Instance, self).__init__(name, host)
self.domain = domain
self.zonefile = 'db.{}'.format(self.domain)
self.remotefile = '/etc/bind/{}'.format(self.zonefile)
Instance.define(
name='example_com',
host='dnsserver.example.com',
domain='example.com',
)
ZoneInfo = collections.namedtuple('ZoneInfo', 'serial primary_ns nameservers')
def parse_zone(domain, filename):
"""Extract some essential information from the zone file.
Returns a ZoneInfo named tuple.
"""
zone = easyzone.zone_from_file(domain, filename)
serial = int(zone.root.soa.serial)
primary_ns = zone.root.soa.mname
nameservers = list(zone.root.records('NS'))
return ZoneInfo(serial=serial, primary_ns=primary_ns,
nameservers=nameservers)
def get_current_serial(domain, ns=''):
"""Get the serial number for the SOA record for ``domain``.
You can ask it to use the specified name server (``ns``).
"""
output = local('host -t soa {domain} {ns}'.format(domain=domain, ns=ns),
capture=True)
words = output.split('\n\n')[-1].split()
if words[1:4] != ["has", "SOA", "record"]:
print(output)
abort("couldn't find current SOA record for {domain}".format(
domain=domain))
try:
return int(words[6])
except (IndexError, ValueError):
print(output)
abort("couldn't figure out current serial {domain}".format(
domain=domain))
def upload_zone_file(zonefile, remotefile, serial):
"""Upload the zone file and mention that in the changelog."""
put(zonefile, remotefile)
changelog('# updated {remotefile} to serial {serial}',
dict(serial=serial, remotefile=remotefile))
def restart_named():
"""Tell named to reload its configuration."""
run_and_changelog('service bind9 reload')
check_named_health()
def check_named_health():
"""Check that one and only one named process is running.
It's seriously not fun when you have two named processes responding
to queries on port 53, but one of them has an outdated DB.
"""
with quiet():
pids = run('pgrep named').split()
if len(pids) != 1:
abort("exacly one named process expected, found {}".format(len(pids)))
def check_named_conf(domain):
"""Check that named knows about this zone."""
if not contains('/etc/bind/named.conf.local', 'zone "{}"'.format(domain)):
if not contains('/etc/bind/named.conf', 'zone "{}"'.format(domain)):
warn("/etc/bind/named.conf.local doesn't know about {}".format(domain))
def commit_changes(remotefile):
"""Commit config file changes to the version control system."""
run("etckeeper commit 'Update {remotefile} via Fabric'".format(remotefile=remotefile))
@task
def deploy(force=False):
"""Push the new zone file to the primary NS."""
instance = get_instance()
force = asbool(force)
zone = parse_zone(instance.domain, instance.zonefile)
current_serial = get_current_serial(instance.domain, zone.primary_ns)
error = warn if force else abort
if zone.serial <= current_serial:
error('serial in {zonefile} ({serial}) is not greater than current'
' serial ({current_serial})'.format(
serial=zone.serial, zonefile=instance.zonefile,
current_serial=current_serial))
with settings(user='root', host_string=instance.host):
install_missing_packages('bind9')
upload_zone_file(instance.zonefile, instance.remotefile, zone.serial)
restart_named()
commit_changes(instance.remotefile)
check_named_conf(instance.domain)
@task
def check():
"""Check if the zone is configured correctly.
Checks that all the nameservers listed in the zone file return SOA
records with the right serial number. Doesn't compare any other SOA
fields.
Doesn't check parent delegation (i.e. does the parent point to the right
primary and secondary NSes?).
Doesn't fully check that the zone is configured correctly on the master
(i.e. it has zone ... { type master; } with the right zone file).
Doesn't check that all secondaries have the zone configured correctly (i.e.
that they have has zone ... { type slave; } with the right zone filename,
and that the list of masters is correct).
Checks for some split-brain scenarios (two named processes running,
returning different results, which has happened) on the primary NS.
"""
instance = get_instance()
zone = parse_zone(instance.domain, instance.zonefile)
if zone.primary_ns not in zone.nameservers:
warn('{primary_ns} is not listed among the NS records'.format(
primary_ns=zone.primary_ns))
serials = [(ns, get_current_serial(instance.domain, ns))
for ns in zone.nameservers]
for ns, serial in serials:
if serial != zone.serial:
warn('serial in {zonefile} ({zone_serial}) does not match'
' the serial returned by {ns} ({ns_serial})'.format(
zonefile=instance.zonefile, zone_serial=zone.serial,
ns=ns.rstrip('.'), ns_serial=serial))
with settings(user='root', host_string=instance.host):
check_named_health()
check_named_conf(instance.domain)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment