Commit dc26d3f9 authored by ale's avatar ale

add DNSSEC support

parent 9b515aae
......@@ -15,32 +15,58 @@ def main():
'-o', '--output-dir', dest='output_dir',
default='.', metavar='DIR',
help='Output directory (default: current dir)')
'--delete', dest='delete', action='store_true',
help='Delete obsolete zone files in output_dir')
'-n', '--dry-run', dest='dry_run', action='store_true',
help='Do not actually write zone files')
dnssec_g = parser.add_option_group('DNSSEC Options')
'--key-dir', dest='key_dir', metavar='DIR',
help='Location of key files')
'--ds-dir', dest='ds_dir', metavar='DIR',
help='Location of DS record files')
'--nsec3-salt', dest='nsec3_salt', metavar='HEX',
help='NSEC3 salt (hex-encoded)')
'--dnssec-refresh', dest='dnssec_refresh', action='store_true',
help='Refresh all DNSSEC signatures')
opts, args = parser.parse_args()
if len(args) != 1:
parser.error('Wrong number of arguments')
# Create a global config dictionary using the environment and (if
# specified) a YAML config file. The advantage of the config file
# is that it lets you define variables which are not just plain
# strings.
config = dict(os.environ)
if opts.config:
with open(opts.config, 'r') as fd:
# Parse the DNS configuration and generate zone data.
zp = zone.ZoneParser(config)
zones = zp.render()
if not zones:
print >>sys.stderr, 'No zones found.'
for zone_name, zone_data in zones.iteritems():
print zone_name
if opts.dry_run:
dst = os.path.join(opts.output_dir, zone_name)
with open(dst, 'w') as fd:
pproc = None
if opts.key_dir and opts.ds_dir and opts.nsec3_salt:
# Enable DNSSEC support.
pproc = zone.DNSSECSigner(
opts.key_dir, opts.ds_dir, opts.nsec3_salt, opts.dnssec_refresh)
# Render the zone data to 'output_dir'.
zw = zone.ZoneWriter(opts.output_dir,
changed, removed = zw.write(zp.render(), postproc=pproc)
if changed or removed:
if __name__ == '__main__':
import os
import re
import socket
import time
......@@ -23,6 +24,15 @@ def _is_ip4(s):
return _is_ip(s, socket.AF_INET)
_SPACES_RE = re.compile(r'\s+')
_NOSERIAL_RE = re.compile(r'^\s*(\d+)\s*;\s*Serial$', re.MULTILINE)
def _zonecmp(a, b):
"""Check if two zones are equal, ignoring Serial and whitespaces."""
return (_SPACES_RE.sub(' ', _NOSERIAL_RE.sub('', a)) ==
_SPACES_RE.sub(' ', _NOSERIAL_RE.sub('', b)))
def _parse_record(value):
# Auto-detect A and AAAA record types.
if _is_ip6(value):
......@@ -40,6 +50,7 @@ def _parse_record(value):
def _to_records(data):
records = []
for key, values in data.iteritems():
# Uppercase keys are attributes, lowercase are DNS records.
if key.isupper():
if not isinstance(values, list):
......@@ -85,7 +96,7 @@ class ZoneParser(object):
self.config = config or {}
self.zones = {'@default': {}}
def merge(self, yamls):
def load(self, yamls):
for objs in yamls:
for y in objs:
......@@ -95,14 +106,12 @@ class ZoneParser(object):
self.zones.setdefault(zone_name, {}).update(zone_data)
def render(self):
zones_out = {}
for zone_name, zone_data in self.zones.iteritems():
if zone_name.startswith('@'):
resolved = self._resolve_references(zone_name, zone_data)
zones_out[zone_name] = _render_zone(
zone_name, resolved, resolved)
return zones_out
output = _render_zone(zone_name, resolved, resolved)
yield (zone_name, zone_data, output)
def _resolve_references(self, zone_name, zone_data):
out = {}
......@@ -140,3 +149,83 @@ def readyaml(filenames):
for f in filenames:
with open(f) as fd:
yield yaml.safe_load_all(fd)
class ZoneWriter(object):
def __init__(self, output_dir, delete=False, dry_run=False):
self.output_dir = output_dir
self.dry_run = dry_run
self.delete_old_files = delete
def write_if_changed(self, data, dst):
if os.path.exists(dst):
with open(dst, 'r') as fd:
if not _zonecmp(, data):
return False
if not self.dry_run:
with open(dst, 'w') as fd:
return True
def write(self, zones, postproc=None):
# Compare the current contents of 'output_dir' with the
# generated output to detect zones that were removed.
existing = set(os.listdir(self.output_dir))
changed = set()
for zone_name, zone_attrs, zone_data in zones:
outfile = zone_name
zone_changed = False
dst = os.path.join(self.output_dir, outfile)
if write_if_changed(zone_data, dst):
zone_changed = True
# Zone file post-processing.
if postproc:
outfile = postproc(
self.output_dir, outfile, zone_name, zone_attrs, zone_changed)
# Wipe what shouldn't be there.
if not self.dry_run and self.delete_old_files:
for filename in existing:
os.remove(os.path.join(self.output_dir, filename))
return changed, existing
class DNSSECSigner(object):
def __init__(self, key_dir, ds_dir, nsec3_salt, refresh):
self.key_dir = key_dir
self.ds_dir = ds_dir
self.nsec3_salt = nsec3_salt
self.refresh = refresh
def sign_zone_file(self, zone_name, infile, outfile):
"""Sign a zone file with DNSSEC records.
:param zone_name: Zone name (origin).
:param infile: Source zone file path.
:param outfile: Destination zone file path.
# Use only one CPU/thread on purpose otherwise the record order is
# not preserved and the results are difficult to compare.
'-a', '-g', '-S',
'-n', '1',
'-3', self.nsec3_salt,
'-K', self.key_dir,
'-d', self.ds_dir,
'-o', zone_name,
'-f', outfile,
def __call__(self, output_dir, filename, zone_name, zone_attrs, zone_changed):
if not zone_attrs.get('DNSSEC'):
return filename
outfile = filename + '.signed'
if zone_changed or self.refresh:
self.sign_zone_file(zone_name, filename, outfile)
return outfile
......@@ -41,8 +41,8 @@ class ZoneParserTest(unittest.TestCase):
def test_template_expansion(self):
result = self.zp.render()
result = list(self.zp.render())
self.assertEquals([''], result.keys())
self.assertEquals('', result[0][0])
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment