Commit 61efd202 authored by ale's avatar ale

initial commit

zonetool is a tool to reduce the pain of managing potentially large
collections of DNS zone files. It offers a simple declarative syntax,
and semantics to merge manual configuration and automated output from
arbitrary sources. It supports templates and inheritance as a way to
simplify large deployments.
The basic entity of the data model is a dictionary corresponding to a
DNS zone. The dictionary key/value pairs correspond to individual DNS
resource records. Some keys are special: they define attributes of
the zone itself - they are usually recognizable because they're
uppercase (while normal DNS record names are lowercase).
Record syntax
A DNS entry consists of a name and one or more resource records, so in
our model the dictionary values can be either strings, or lists of
strings (a single string is just a shorthand syntax for a list
containing a single element). All names are relative to the zone
origin, except for the special name consisting of a single underscore
(``_``), which stands for the zone origin itself.
Each resource record consists of a DNS RR type, and the associated
data, separated by a space (the resource data can contain further
spaces). The resource record will be written verbatim to the resulting
zone file (the ``IN`` namespace is assumed), for example::
- A
- AAAA 2001:1:2:3::4
- MX 10
describes three different DNS records, all for the zone origin: one
IPv4 address, an IPv6 one, and a MX record for SMTP delivery.
Since A and AAAA records are usually the most common by far, a special
shorthand notation allows you to simply specify the IP address, and
the record type will be automatically determined. So, the above zone
could have been written as::
- 2001:1:2:3::4
- MX 10
Templates and inheritance
Each zone can have an ``EXTENDS`` attribute, which contains a list of
zone names that will be merged into the current one. It is possible to
use this feature as a full-blown templating system by taking advantage
of the fact that zones whose name starts with a ``@`` are not going to
be present in the output. In fact, one such zone is always defined:
``@default`` (empty by default), and it is automatically added to the
``EXTENDS`` attribute of every zone that does **not** start with a
``@`` character.
Perhaps some examples can clarify the mechanism::
- NS
- NS
In the above case, the two zones defined will both have NS records
pointing at ```` and ````.
Another example::
- @base
_spf: TXT "v=spf1 -all"
_: NS
here the ```` zone will not only include the NS record
from ``@default``, but it will also contain the ``_spf`` TXT record
from the ``@base`` template.
Variable substitution and configuration
Along with the zone dictionaries, it is also possible to specify
arbitrary resource records in a global *configuration*: these records
are not associated with a specific zone, and they can be substituted
in-place using a shell-like variable expansion syntax.
For instance, assume that you have a list of IPs which appear in
multiple records in your DNS zones: it would be nice to maintain
the full list in a single place. So, if your configuration contains::
FRONTENDS = ['', '']
it is then possible to define a zone as follows::
Note that when using this syntax, the resource record can't be a list.
from setuptools import setup, find_packages
description='Painless DNS zone management',
'console_scripts': [
'zoneutil =',
import os
import socket
import time
class Error(Exception):
def _is_ip(s, family):
socket.inet_pton(family, s)
return True
return False
def _is_ip6(s):
return _is_ip(s, socket.AF_INET6)
def _is_ip4(s):
return _is_ip(s, socket.AF_INET)
def _parse_record(value):
# Auto-detect A and AAAA record types.
if _is_ip6(value):
rtype, rdata = ('AAAA', value)
elif _is_ip4(value):
rtype, rdata = ('A', value)
rtype, rdata = value.split(' ', 1)
except ValueError:
raise Error('invalid record value "%s"' % value)
return rtype.upper(), rdata
def _to_records(data):
records = []
for key, values in data.iteritems():
if key.isupper():
if not isinstance(values, list):
values = [values]
for value in values:
rtype, rdata = _parse_record(value)
records.append(('' if key == '_' else key, rtype, rdata))
return sorted(records)
def _render_zone(name, data, attrs):
records = _to_records(data)
nameservers = [x[2] for x in records if x[0] == '' and x[1] == 'NS']
if not nameservers:
nameservers = ['localhost.']
preamble = '''; Autogenerated, DO NOT EDIT!
$TTL %(ttl)d
@\t\tIN\tSOA\t%(first_nameserver)s\thostmaster.%(origin)s. (
\t\t\t\t%(serial)d ; Serial
\t\t\t\t%(neg_cache_ttl)d )
''' % {'origin': name,
'serial': attrs.get('SERIAL', int(time.time())),
'ttl': attrs.get('TTL', 3600),
'refresh_ttl': attrs.get('REFRESH', 43200),
'retry_ttl': attrs.get('RETRY', 3600),
'expire_ttl': attrs.get('EXPIRE', 2419200),
'neg_cache_ttl': attrs.get('NEG_CACHE', 3600),
'first_nameserver': nameservers[0]}
out = [preamble]
for key, rtype, rdata in records:
out.append('%s\t\tIN\t%s\t%s\n' % (key, rtype, rdata))
return ''.join(out)
class ZoneParser(object):
def __init__(self, config=None):
self.config = config or {}
self.zones = {'@default': {}}
def merge(self, yamls):
for objs in yamls:
for y in objs:
def _merge(self, data):
for zone_name, zone_data in data.iteritems():
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
def _resolve_references(self, zone_name, zone_data):
out = {}
bases = ['@default'] if not zone_name.startswith('@') else []
bases.extend(zone_data.get('EXTENDS', []))
for base in bases:
base, self.zones[base]))
except KeyError:
raise Error('undefined template "%s"' % base)
for key, value in zone_data.iteritems():
if isinstance(value, basestring) and value.startswith('$'):
var_name = value[1:]
out[key] = self.config[var_name]
except KeyError:
raise Error('undefined config variable "%s"' % var_name)
out[key] = value
return out
def walk(root_dir):
for dirpath, dirnames, filenames in os.walk(root_dir):
for f in filenames:
if f.endswith('.yml') and not f.startswith('.'):
yield os.path.join(dirpath, f)
def readyaml(filenames):
for f in filenames:
with open(f) as fd:
yield yaml.safe_load_all(fd)
import yaml
import unittest
from StringIO import StringIO
from import *
- "@default"
- NS
- NS
- TXT "blahblah.onion."
def _loadyaml(strs):
for s in strs:
yield yaml.safe_load_all(StringIO(s))
class ZoneParserTest(unittest.TestCase):
def setUp(self):
self.zp = ZoneParser({
'FRONTENDS': ['', ''],
def test_template_expansion(self):
result = self.zp.render()
self.assertEquals([''], result.keys())
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