Commit dc26d3f9 authored by ale's avatar ale

add DNSSEC support

parent 9b515aae
...@@ -15,32 +15,58 @@ def main(): ...@@ -15,32 +15,58 @@ def main():
'-o', '--output-dir', dest='output_dir', '-o', '--output-dir', dest='output_dir',
default='.', metavar='DIR', default='.', metavar='DIR',
help='Output directory (default: current dir)') help='Output directory (default: current dir)')
parser.add_option(
'--delete', dest='delete', action='store_true',
help='Delete obsolete zone files in output_dir')
parser.add_option( parser.add_option(
'-n', '--dry-run', dest='dry_run', action='store_true', '-n', '--dry-run', dest='dry_run', action='store_true',
help='Do not actually write zone files') help='Do not actually write zone files')
dnssec_g = parser.add_option_group('DNSSEC Options')
dnssec_g.add_option(
'--key-dir', dest='key_dir', metavar='DIR',
help='Location of key files')
dnssec_g.add_option(
'--ds-dir', dest='ds_dir', metavar='DIR',
help='Location of DS record files')
dnssec_g.add_option(
'--nsec3-salt', dest='nsec3_salt', metavar='HEX',
help='NSEC3 salt (hex-encoded)')
dnssec_g.add_option(
'--dnssec-refresh', dest='dnssec_refresh', action='store_true',
help='Refresh all DNSSEC signatures')
opts, args = parser.parse_args() opts, args = parser.parse_args()
if len(args) != 1: if len(args) != 1:
parser.error('Wrong number of arguments') 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) config = dict(os.environ)
if opts.config: if opts.config:
with open(opts.config, 'r') as fd: with open(opts.config, 'r') as fd:
config.update(yaml.safe_load(fd)) config.update(yaml.safe_load(fd))
# Parse the DNS configuration and generate zone data.
zp = zone.ZoneParser(config) zp = zone.ZoneParser(config)
zp.merge(zone.readyaml(zone.walk(args[0]))) zp.load(zone.readyaml(zone.walk(args[0])))
zones = zp.render()
if not zones: pproc = None
print >>sys.stderr, 'No zones found.' if opts.key_dir and opts.ds_dir and opts.nsec3_salt:
# Enable DNSSEC support.
for zone_name, zone_data in zones.iteritems(): pproc = zone.DNSSECSigner(
print zone_name opts.key_dir, opts.ds_dir, opts.nsec3_salt, opts.dnssec_refresh)
if opts.dry_run:
continue # Render the zone data to 'output_dir'.
dst = os.path.join(opts.output_dir, zone_name) zw = zone.ZoneWriter(opts.output_dir,
with open(dst, 'w') as fd: delete=opts.delete,
fd.write(zone_data) dry_run=opts.dry_run)
changed, removed = zw.write(zp.render(), postproc=pproc)
if changed or removed:
sys.exit(0)
sys.exit(1)
if __name__ == '__main__': if __name__ == '__main__':
......
import os import os
import re
import socket import socket
import time import time
...@@ -23,6 +24,15 @@ def _is_ip4(s): ...@@ -23,6 +24,15 @@ def _is_ip4(s):
return _is_ip(s, socket.AF_INET) 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): def _parse_record(value):
# Auto-detect A and AAAA record types. # Auto-detect A and AAAA record types.
if _is_ip6(value): if _is_ip6(value):
...@@ -40,6 +50,7 @@ def _parse_record(value): ...@@ -40,6 +50,7 @@ def _parse_record(value):
def _to_records(data): def _to_records(data):
records = [] records = []
for key, values in data.iteritems(): for key, values in data.iteritems():
# Uppercase keys are attributes, lowercase are DNS records.
if key.isupper(): if key.isupper():
continue continue
if not isinstance(values, list): if not isinstance(values, list):
...@@ -85,7 +96,7 @@ class ZoneParser(object): ...@@ -85,7 +96,7 @@ class ZoneParser(object):
self.config = config or {} self.config = config or {}
self.zones = {'@default': {}} self.zones = {'@default': {}}
def merge(self, yamls): def load(self, yamls):
for objs in yamls: for objs in yamls:
for y in objs: for y in objs:
self._merge(y) self._merge(y)
...@@ -95,14 +106,12 @@ class ZoneParser(object): ...@@ -95,14 +106,12 @@ class ZoneParser(object):
self.zones.setdefault(zone_name, {}).update(zone_data) self.zones.setdefault(zone_name, {}).update(zone_data)
def render(self): def render(self):
zones_out = {}
for zone_name, zone_data in self.zones.iteritems(): for zone_name, zone_data in self.zones.iteritems():
if zone_name.startswith('@'): if zone_name.startswith('@'):
continue continue
resolved = self._resolve_references(zone_name, zone_data) resolved = self._resolve_references(zone_name, zone_data)
zones_out[zone_name] = _render_zone( output = _render_zone(zone_name, resolved, resolved)
zone_name, resolved, resolved) yield (zone_name, zone_data, output)
return zones_out
def _resolve_references(self, zone_name, zone_data): def _resolve_references(self, zone_name, zone_data):
out = {} out = {}
...@@ -140,3 +149,83 @@ def readyaml(filenames): ...@@ -140,3 +149,83 @@ def readyaml(filenames):
for f in filenames: for f in filenames:
with open(f) as fd: with open(f) as fd:
yield yaml.safe_load_all(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(fd.read(), data):
return False
if not self.dry_run:
with open(dst, 'w') as fd:
fd.write(data)
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):
changed.add(zone_name)
zone_changed = True
# Zone file post-processing.
if postproc:
outfile = postproc(
self.output_dir, outfile, zone_name, zone_attrs, zone_changed)
existing.discard(outfile)
# 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.
subprocess.check_call([
'dnssec-signzone',
'-a', '-g', '-S',
'-n', '1',
'-3', self.nsec3_salt,
'-K', self.key_dir,
'-d', self.ds_dir,
'-o', zone_name,
'-f', outfile,
infile])
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): ...@@ -41,8 +41,8 @@ class ZoneParserTest(unittest.TestCase):
}) })
def test_template_expansion(self): def test_template_expansion(self):
self.zp.merge(_loadyaml(TEST_DATA)) self.zp.load(_loadyaml(TEST_DATA))
result = self.zp.render() result = list(self.zp.render())
self.assertEquals(['autistici.org'], result.keys()) self.assertTrue(result)
self.assertTrue(result['autistici.org']) self.assertEquals('autistici.org', 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