Commit 61efd202 authored by ale's avatar ale

initial commit

parents
*.pyc
*.egg-info
build
========
zonetool
========
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::
inventati.org:
_:
- A 1.2.3.4
- AAAA 2001:1:2:3::4
- MX 10 mx1.inventati.org.
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::
inventati.org:
_:
- 1.2.3.4
- 2001:1:2:3::4
- MX 10 mx1.inventati.org.
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::
autistici.org:
www: 1.2.3.4
inventati.org:
www: 2.3.4.5
"@default":
_:
- NS ns1.example.com.
- NS ns2.example.com.
In the above case, the two zones defined will both have NS records
pointing at ``ns1.example.com`` and ``ns2.example.com``.
Another example::
autistici.org:
EXTENDS:
- @base
www: 1.2.3.4
"@base":
_spf: TXT "v=spf1 -all"
"@default":
_: NS ns1.example.com.
here the ``autistici.org`` 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 = ['1.2.3.4', '2.3.4.5']
it is then possible to define a zone as follows::
autistici.org:
_: "$FRONTENDS"
www: "$FRONTENDS"
Note that when using this syntax, the resource record can't be a list.
#!/usr/bin/python
from setuptools import setup, find_packages
setup(
name='zonetool',
version='0.1',
description='Painless DNS zone management',
author='ale',
author_email='ale@incal.net',
url='http://git.autistici.org/ale/zonetool',
install_requires=[],
test_requires=[],
setup_requires=[],
zip_safe=True,
packages=find_packages(),
package_data={},
entry_points={
'console_scripts': [
'zoneutil = zonetool.zone:main',
],
},
)
import os
import socket
import time
class Error(Exception):
pass
def _is_ip(s, family):
try:
socket.inet_pton(family, s)
return True
except:
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)
else:
try:
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():
continue
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%(refresh_ttl)d
\t\t\t\t%(retry_ttl)d
\t\t\t\t%(expire_ttl)d
\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:
self._merge(y)
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('@'):
continue
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:
try:
out.update(self._resolve_references(
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:]
try:
out[key] = self.config[var_name]
except KeyError:
raise Error('undefined config variable "%s"' % var_name)
else:
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 zonetool.zone import *
TEST_DATA = [
'''
autistici.org:
EXTENDS:
- "@default"
_:
- 82.94.249.234
- 82.221.99.153
www: "$FRONTENDS"
''',
'''
"@default":
_:
- NS ns1.autistici.org.
- NS ns2.autistici.org.
''',
'''
"@default":
onion:
- 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': ['82.94.249.234', '82.221.99.153'],
})
def test_template_expansion(self):
self.zp.merge(_loadyaml(TEST_DATA))
result = self.zp.render()
self.assertEquals(['autistici.org'], result.keys())
self.assertTrue(result['autistici.org'])
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