write bind_conf.py
authorTero Marttila <terom@fixme.fi>
Thu, 02 Apr 2009 22:52:26 +0300
changeset 4 8b633782f02d
parent 3 ff98fa9b84ce
child 5 86b05c0ab5cd
write bind_conf.py
addr.py
bind_conf.py
conf.py
dhcp.py
dhcp_conf.py
settings/hosts.py
test_bind.py
test_conf.py
test_dhcp.py
--- a/addr.py	Thu Apr 02 21:11:01 2009 +0300
+++ b/addr.py	Thu Apr 02 22:52:26 2009 +0300
@@ -15,6 +15,20 @@
         """
     
         super(IP, self).__init__(address)
+    
+    def is_v4 (self) :
+        """
+            Returns True if the address is an IPv4 address
+        """
+
+        return self.version() == 4
+
+    def is_v6 (self) :
+        """
+            Returns True if the address is an IPv6 address
+        """
+
+        return self.version() == 6
 
 class Network (IPy.IP, object) :
     """
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bind_conf.py	Thu Apr 02 22:52:26 2009 +0300
@@ -0,0 +1,380 @@
+"""
+    Configuration file output for the ISC DNS server: BIND
+"""
+
+import conf
+
+class Object (conf.ConfObject) :
+    """
+        Our own version of ConfObject that knows how to format comments
+    """
+
+    def _fmt_comments (self) :
+        """
+            Format comments using the standard format:
+                ";" <comment>
+        """
+
+        for comment in self.comments :
+            if comment is not None :
+                yield "; %s" % (comment, )
+
+class ZoneFile (Object, conf.File) :
+    """
+        A zone file containing a bunch of directives, resource records and comments
+    """
+
+    def __init__ (self, name, path, ttl=None, comment=None) :
+        """
+            @param name the name of the zonefile, for status stuff
+            @param path the path to the zonefile
+            @param ttl default TTL to use
+            @param comment optional comments
+        """
+        
+        Object.__init__(comment)
+        conf.File.__init__(name, path)
+
+        # init
+        self.objects = []
+
+        if ttl :
+            self.add_directive(TTLDirective(ttl))
+
+    def add_obj (self, obj) :
+        """
+            Add an Object onto the list of things to include in this zone file
+        """
+
+        self.objects.append(obj)
+
+    # various aliases...
+    add_comment = add_record = add_directive = add_obj
+
+class Comment (Object) :
+    """
+        A comment, is, well, a comment :)
+    """
+
+    def __init__ (self, comment) :
+        """
+            @param comment the comment string
+        """
+
+        Object.__init__(self, [comment])
+    
+    def fmt_lines (self) :
+        """
+            Yield a single line with the comment
+        """
+
+        return self._fmt_comments()
+
+class Label (object) :
+    """
+        A label, as used in a ResourceRecord, either as the label, or the rdata for various resource types
+
+        You can also use strs, this just implements a __str__ method
+    """
+
+    def __init__ (self, label) :
+        """
+            @param label the literal label to use
+        """
+
+        self.label = label
+
+    def __str__ (self) :
+        return self.label
+
+class Origin (Label) :
+    """
+        A label that represents the zone's origin
+    """
+
+    def __init__ (self) :
+        pass
+
+    def __str__ (self) :
+        return '@'
+
+class FQDN (Label) :
+    """
+        A label that represents the given external domain (i.e. this adds the . to the end that people always forget).
+    """
+
+    def __init__ (self, fqdn) :
+        self.fqdn = fqdn
+
+    def __str__ (self) :
+        return "%s." % (self.fqdn, )
+
+class Interval (object) :
+    """
+        A time interval suitable for use in SOA records
+    """
+
+    def __init__ (self, s=None, m=None, h=None, d=None) :
+        """
+            @param s seconds
+            @param m minutes
+            @param h hours
+            @param d days
+        """
+
+        self.s = s
+        self.m = m
+        self.h = h
+        self.d = d
+
+    def __str__ (self) :
+        """
+            If only seconds were given, just return those directly, otherwise, apply units
+        """
+
+        if self.s and not self.m and not self.h and not self.d :
+            return str(self.s)
+
+        else :
+            return "%s%s%s%s" % (
+                    "%ds" % self.s if self.s else '',
+                    "%dm" % self.m if self.m else '',
+                    "%dh" % self.h if self.h else '',
+                    "%dd" % self.d if self.d else ''
+                )
+
+class ResourceRecord (Object) :
+    """
+        A generic resource record for a BIND zone file
+    """
+
+    def __init__ (self, label, type, rdata, cls='IN', ttl=None, **kwargs) :
+        """
+            @param label the "name" of this record, or None to referr to the previous record's label
+            @param type the type as a string ('A', 'TXT', etc.)
+            @param rdata the rdata, as a raw string
+            @param cls the class, e.g. 'IN'
+            @param ttl the time-to-live value in seconds, or None to omit it
+        """
+
+        super(ResourceRecord, self).__init__(**kwargs)
+
+        self.label = label
+        self.type = type
+        self.rdata = rdata
+        self.cls = cls
+        self.ttl = ttl
+
+    def fmt_lines (self) :
+        """
+            Just format the lines, eh
+        """
+        
+        # prefix comments
+        for line in self._fmt_comments() :
+            yield line
+
+        # then format the line
+        # XXX: TTL?
+        yield "%30s %4s%4s %8s %s" % (self.label if self.label is not None else '', str(self.ttl) if self.ttl else '', self.cls, self.type, self.rdata)
+
+class SOA (ResourceRecord) :
+    """
+        "Identifies the start of a zone of authority", must be the first record
+    """
+
+    def __init__ (self, label, primary_ns, hostmaster, serial, refresh, retry, expire, minimum, **kwargs) :
+        """
+            @param label the "name" of the zone, usually ORIGIN
+            @param primary_ns the address of the primary NS server
+            @param hostmaster the mailbox of the zone's hostmaster
+            @param serial the serial number of the zone as an integer
+            @param refresh time interval between zone refreshes in seconds
+            @param retry time interval between retrys for failed refreshes
+            @param expire time interval before zone data can no longer be considered authorative
+            @param minimum minimum TTL for RRs in this zone
+        """
+
+        super(SOA, self).__init__(label, 'SOA', "%s %s ( %s %s %s %s %s )" % (
+                primary_ns, hostmaster, serial, refresh, retry, expire, minimum
+            ), **kwargs)
+
+class A (ResourceRecord) :
+    """
+        An IPv4 forward address
+    """
+
+    def __init__ (self, label, addr, **kwargs) :
+        """
+            @param label the "name" of the address
+            @param addr the IPv4 target address
+        """
+
+        assert(addr.is_v4())
+
+        super(A, self).__init__(label, 'A', addr, **kwargs)
+
+class AAAA (ResourceRecord) :
+    """
+        An IPv6 forward address
+    """
+
+    def __init__ (self, label, addr, **kwargs) :
+        """
+            @param label the "name" of the address
+            @param addr the IPv6 target address
+        """
+        
+        assert(addr.is_v6())
+
+        super(AAAA, self).__init__(label, 'AAAA', addr.strCompressed(), **kwargs)
+
+class CNAME (ResourceRecord) :
+    """
+        A canonical-name alias
+    """
+
+    def __init__ (self, label, target, **kwargs) :
+        """
+            @param label the "name" of the alias
+            @param target the alias target
+        """
+
+        super(CNAME, self).__init__(label, 'CNAME', target, **kwargs)
+
+class TXT (ResourceRecord) :
+    """
+        A human-readable information record
+    """
+
+    def __init__ (self, label, text, **kwargs) :
+        """
+            @param label the "name" of the text record
+            @param text the text data, shouldn't contain any quotes...
+        """
+
+        super(TXT, self).__init__(label, 'TXT', '"%s"' % text, **kwargs)
+
+class MX (ResourceRecord) :
+    """
+        A mail-exchange definition
+    """
+
+    def __init__ (self, label, pref, exchange, **kwargs) :
+        """
+            @param label the "name" of the domain to handle mail for
+            @param pref the numerical preference for this exchange
+            @param exchange the domain name of the mail exchange (SMTP server)
+        """
+
+        super(MX, self).__init__(label, 'MX', "%d %s" % (pref, exchange), **kwargs)
+
+class NS (ResourceRecord) :
+    """
+        An authorative name server
+    """
+
+    def __init__ (self, label, nsname, **kwargs) :
+        """
+            @param label the "name" of the domain to have a nameserver for
+            @param nsname the name of the nameserver
+        """
+
+        super(NS, self).__init__(label, 'NS', nsname)
+
+class PTR (ResourceRecord) :
+    """
+        An IPv4/IPv6 reverse address
+    """
+
+    def __init__ (self, addr, name, **kwargs) :
+        """
+            @param addr the addr.IP to map via in-addr.arpa
+            @param name the name to map the address to
+        """
+
+        # XXX: quick hack, this gives an absolute name
+        label = addr.reverseName()
+        
+        super(PTR, self).__init__(label, 'PTR', name)
+
+class Directive (Object) :
+    """
+        Special directives that can be used in zone files to control behaviour
+    """
+
+    def __init__ (self, name, *args, **kwargs) :
+        """
+            @param name the $NAME bit
+            @param args optional list of space-seprated arguments, Nones are ignored
+        """
+
+        super(Directive, self).__init__(**kwargs)
+
+        self.name = name
+        self.args = [arg for arg in args if arg is not None]
+
+    def fmt_lines (self) :
+        # prefix comments
+        for line in self._fmt_comments() :
+            yield line
+
+        # then format it
+        yield "$%s%s" % (self.name, (' ' + ' '.join(str(arg) for arg in self.args)) if self.args else '')
+
+class OriginDirective (Directive) :
+    """
+        Set the origin used to resolve the zone's labels.
+
+        Note that the origin label is not absolute by default - use FQDN for that
+    """
+
+    def __init__ (self, origin, **kwargs) :
+        """
+            @param origin the origin label
+        """
+
+        super(OriginDirective, self).__init__('ORIGIN', origin, **kwargs)
+
+class TTLDirective (Directive) :
+    """
+        Set the TTL used for records by default
+    """
+
+    def __init__ (self, ttl, **kwargs) :
+        """
+            @param ttl the new ttl to use
+        """
+
+        super(TTLDirective, self).__init__('TTL', ttl, **kwargs)
+
+class IncludeDirective (Directive) :
+    """
+        Include another zoen file, optionally with a different origin
+    """
+
+    def __init__ (self, filename, origin=None, **kwargs) :
+        """
+            @param filename the zone file to include
+            @param origin the optional origin to process the zonefile with
+        """
+
+        super(IncludeDirective, self).__init__('INCLUDE', filename, origin, **kwargs)
+
+class GenerateDirective (Directive) :
+    """
+        Generate a bunch of numbered records using an expression for the label and rdata.
+
+        At the simplest, any "$" in the expression is replaced with the value of the iterator.
+    """
+
+    def __init__ (self, range, lhs, type, rhs, ttl=None, cls=None, **kwargs) :
+        """
+            @param range (start, stop, step) tuple
+            @param lhs expression to generate the label
+            @param type the resource record type
+            @param rhs expression to generate the rdata
+        """
+
+        super(GenerateDirective, self).__init__('GENERATE', '%d-%d' % range, lhs, ttl, cls, type, rhs, **kwargs)
+
--- a/conf.py	Thu Apr 02 21:11:01 2009 +0300
+++ b/conf.py	Thu Apr 02 22:52:26 2009 +0300
@@ -65,6 +65,7 @@
     def write (self) :
         """
             Write out a new config file with this config's stuff using the following procedure:
+
                 * lock the real file
                 * open a new temporary file, and write out the objects into that, closing it
                 * move the real file out of the way
--- a/dhcp.py	Thu Apr 02 21:11:01 2009 +0300
+++ b/dhcp.py	Thu Apr 02 22:52:26 2009 +0300
@@ -10,7 +10,7 @@
     """
 
     def __init__ (self, name=dhcpc.ConfFile.DEFAULT_NAME, path=dhcpc.ConfFile.DEFAULT_PATH, 
-            settings=None, options=None, shared_network=False, subnets=None, hosts=None
+            settings=None, options=None, shared_network=False, subnets=None, hosts=None, comment=None
     ) :
         """
             Create a full configuration file for the given settings:
@@ -23,7 +23,7 @@
 
         """
 
-        dhcpc.ConfFile.__init__(self, name, path)
+        dhcpc.ConfFile.__init__(self, name, path, comment=comment)
 
         # define global settings
         if settings :
@@ -49,7 +49,7 @@
         A subnet declaration with a router, and optionally a dynamic address pool, and allow/deny unknown clients
     """
 
-    def __init__ (self, subnet, router_idx=1, range=None, unknown_clients=None) :
+    def __init__ (self, subnet, router_idx=1, range=None, unknown_clients=None, comment=None) :
         """
             @param subnet the addr.IP representing the subnet
             @param router_idx the subnet[index] of the default gateway
@@ -65,7 +65,7 @@
             dhcpc.Option("routers", subnet[router_idx]),
             dhcpc.Parameter("range", subnet[range[0]], subnet[range[1]]) if range else None,
             dhcpc.Parameter(unknown_clients, "unknown-clients") if unknown_clients else None,
-        ])
+        ], comment=comment)
 
 
 class Host (dhcpc.Host) :
@@ -73,9 +73,9 @@
         A host declaration with a hardware address and a IP address
     """
 
-    def __init__ (self, hostname, mac_addr, ip_addr) :
+    def __init__ (self, hostname, mac_addr, ip_addr, comment=None) :
         super(Host, self).__init__(hostname, params=[
             dhcpc.Parameter("hardware ethernet", mac_addr),
             dhcpc.Parameter("fixed-address", ip_addr)
-        ])
+        ], comment=comment)
 
--- a/dhcp_conf.py	Thu Apr 02 21:11:01 2009 +0300
+++ b/dhcp_conf.py	Thu Apr 02 22:52:26 2009 +0300
@@ -115,13 +115,13 @@
     DEFAULT_NAME = "dhcpd.conf"
     DEFAULT_PATH = "/etc/dhcp3/dhcpd.conf"
 
-    def __init__ (self, name=DEFAULT_NAME, path=DEFAULT_PATH, params=None, decls=None) :
+    def __init__ (self, name=DEFAULT_NAME, path=DEFAULT_PATH, params=None, decls=None, comment=None) :
         """
             Initialize the dhcpd config file, but don't open it yet.
         """
         
         conf.File.__init__(self, name, path)
-        Section.__init__(self, params, decls)
+        Section.__init__(self, params, decls, comment)
 
 class Statement (Object) :
     """
@@ -270,14 +270,12 @@
         }
     """
 
-    def __init__ (self, name, params=[], decls=[]) :
+    def __init__ (self, name, *args, **kwargs) :
         """
             @param name the name of the shared-subnet
-            @param params optional parameters
-            @param decls the iterable of subnets or other declarations in the shared network
         """
 
-        super(SharedNetwork, self).__init__("shared-network", [name], params, decls)
+        super(SharedNetwork, self).__init__("shared-network", [name], *args, **kwargs)
 
 class Subnet (Declaration) :
     """
@@ -290,14 +288,12 @@
         }
     """
 
-    def __init__ (self, network, params=None, decls=None) :
+    def __init__ (self, network, *args, **kwargs) :
         """
             @param network the addr.Network for the subnet
-            @param params optional parameters
-            @param decls optional decls, e.g. subnets
         """
 
-        super(Subnet, self).__init__("subnet", [network.net(), "netmask", network.netmask()], params, decls)
+        super(Subnet, self).__init__("subnet", [network.net(), "netmask", network.netmask()], *args, **kwargs)
 
 class Group (Declaration) :
     """
@@ -309,8 +305,8 @@
         }
     """
 
-    def __init__ (self, params=None, decls=None) :
-        super(Group, self).__init__("group", [], params, decls)
+    def __init__ (self, *args, **kwargs) :
+        super(Group, self).__init__("group", [], *args, **kwargs)
 
 
 class Host (Declaration) :
@@ -328,18 +324,18 @@
         }
     """
 
-    def __init__ (self, hostname, params=None, decls=None) :
-        super(Host, self).__init__("host", [hostname], params, decls)
+    def __init__ (self, hostname, *args, **kwargs) :
+        super(Host, self).__init__("host", [hostname], *args, **kwargs)
 
 class Option (Parameter) :
     """
         A generic 'option' parameter for a dhcpd.conf file
     """
 
-    def __init__ (self, name, *args) :
+    def __init__ (self, name, *args, **kwargs) :
         """
             Formatted as a Satement with a name of "option <name>".
         """
 
-        super(Option, self).__init__("option %s" % name, *args)
+        super(Option, self).__init__("option %s" % name, *args, **kwargs)
 
--- a/settings/hosts.py	Thu Apr 02 21:11:01 2009 +0300
+++ b/settings/hosts.py	Thu Apr 02 22:52:26 2009 +0300
@@ -14,8 +14,8 @@
 
 shared_network  = 'PVL'
 subnets         = [
-    Subnet(Network('194.197.235.0/24'), router_idx=1, range=(26, 70), unknown_clients='allow'),
-    Subnet(Network('192.168.0.0/23'),   router_idx=1, unknown_clients='deny'),
+    Subnet(Network('194.197.235.0/24'), router_idx=1, range=(26, 70), unknown_clients='allow', comment="Public network"),
+    Subnet(Network('192.168.0.0/23'),   router_idx=1, unknown_clients='deny', comment="Internal network"),
 ]
 
 hosts           = [
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test_bind.py	Thu Apr 02 22:52:26 2009 +0300
@@ -0,0 +1,70 @@
+#!/usr/bin/env python2.5
+"""
+    Test bind_conf
+"""
+
+import bind_conf as bindc
+import test_conf, addr
+
+import unittest
+
+class TestBINDConf (test_conf._TestConfBase) :
+    def test_comment (self) :
+        self.assert_obj(bindc.Comment("test comment 2"), [ "; test comment 2" ])
+    
+    def assert_str (self, label, value) :
+        self.assertEqual(str(label), value)
+
+    def test_label (self) :
+        self.assert_str(bindc.Label("foo"), "foo")
+
+    def test_origin (self) :
+        self.assert_str(bindc.Origin(), "@")
+
+    def test_fqdn (self) :
+        self.assert_str(bindc.FQDN("foo.com"), "foo.com.")
+
+    def test_interval (self) :
+        self.assert_str(bindc.Interval(12), "12")
+        self.assert_str(bindc.Interval(12, 1), "12s1m")
+        self.assert_str(bindc.Interval(h=2, d=5), "2h5d")
+
+    def assert_rec (self, obj, lines) :
+        """
+            Does a whitespace-insensitive compare of the record's formatted output and the given lines
+        """
+
+        for obj_line, line in zip(obj.fmt_lines(), lines) :
+            obj_line = ' '.join(obj_line.split())
+            self.assertEqual(obj_line, line)
+
+    def test_record (self) :
+        self.assert_rec(bindc.ResourceRecord(None, 'A', addr.IP("1.2.3.4"), cls='TST', ttl=60), [ "60 TST A 1.2.3.4" ])
+        self.assert_rec(bindc.ResourceRecord('foo', 'CNAME', 'blaa'), [ "foo IN CNAME blaa" ])
+        self.assert_rec(bindc.ResourceRecord('bar', 'CNAME', bindc.FQDN('example.com')), [ "bar IN CNAME example.com." ])
+        
+    def test_record_types (self) :
+        self.assert_rec(bindc.SOA(None, 'ns1', 'hostmaster', '2009040200', bindc.Interval(1), 2, 3, 4), [
+                "IN SOA ns1 hostmaster ( 2009040200 1 2 3 4 )"
+            ])
+        self.assert_rec(bindc.A('foo', addr.IP("1.2.3.4")), [ "foo IN A 1.2.3.4" ])
+        self.assert_rec(bindc.AAAA('foo2', addr.IP("2001::5")), [ "foo2 IN AAAA 2001::5" ])
+        self.assert_rec(bindc.CNAME('foo3', bindc.FQDN("foo.com")), [ "foo3 IN CNAME foo.com." ])
+        self.assert_rec(bindc.TXT('test4', "this is some text"), [ "test4 IN TXT \"this is some text\"" ])
+        self.assert_rec(bindc.MX(None, 10, "foo"), [ "IN MX 10 foo" ])
+        self.assert_rec(bindc.NS(None, "ns2"), [ "IN NS ns2" ])
+        self.assert_rec(bindc.PTR(addr.IP("1.2.3.4"), 'bar'), [ "4.3.2.1.in-addr.arpa. IN PTR bar" ])
+    
+    def test_directive (self) :
+        self.assert_obj(bindc.Directive("TEST", "a", None, 2, comments=["hmm..."]), [
+                "; hmm...",
+                "$TEST a 2"
+            ])
+        self.assert_obj(bindc.OriginDirective(bindc.FQDN("foo.com")), [ "$ORIGIN foo.com." ])
+        self.assert_obj(bindc.TTLDirective(bindc.Interval(h=2)), [ "$TTL 2h" ])
+        self.assert_obj(bindc.IncludeDirective("db.foo.com"), [ "$INCLUDE db.foo.com" ])
+        self.assert_obj(bindc.GenerateDirective((1, 10), "dyn${0,2}", 'A', "1.2.3.$"), [ "$GENERATE 1-10 dyn${0,2} A 1.2.3.$" ])
+
+if __name__ == '__main__' :
+    unittest.main()
+
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/test_conf.py	Thu Apr 02 22:52:26 2009 +0300
@@ -0,0 +1,16 @@
+"""
+    Test conf.py
+"""
+
+import unittest
+
+class _TestConfBase (unittest.TestCase) :
+    def assert_obj (self, obj, lines) :
+        """
+            Formats the given conf.Object and compares the output against the given lines
+        """
+        
+        for obj_line, line in zip(obj.fmt_lines(), lines) :
+            self.assertEqual(obj_line, line)
+
+
--- a/test_dhcp.py	Thu Apr 02 21:11:01 2009 +0300
+++ b/test_dhcp.py	Thu Apr 02 22:52:26 2009 +0300
@@ -1,22 +1,14 @@
 #!/usr/bin/env python2.5
 """
-    Test conf_dhcp
+    Test dhcp_conf
 """
 
-import dhcp_conf as dhcpc, dhcp, addr
+import dhcp_conf as dhcpc
+import test_conf, dhcp, addr
 
 import unittest
 
-class _TestConfObj (unittest.TestCase) :
-    def assert_obj (self, obj, lines) :
-        """
-            Formats the given conf.Object and compares the output against the given lines
-        """
-        
-        for obj_line, line in zip(obj.fmt_lines(), lines) :
-            self.assertEqual(obj_line, line)
-
-class TestDHCPConf (_TestConfObj) :
+class TestDHCPConf (test_conf._TestConfBase) :
     def assert_stmt (self, stmt, line) :
         """
             Formats the given Statement, and compares the output against the given line.
@@ -120,7 +112,8 @@
     
 class TestDHCP (_TestConfObj) :
     def test_host (self) :
-        self.assert_obj(dhcp.Host("testhost", addr.MAC("12:34:56:78:90:ab"), addr.IP("1.2.3.4")), [
+        self.assert_obj(dhcp.Host("testhost", addr.MAC("12:34:56:78:90:ab"), addr.IP("1.2.3.4"), comment="foo"), [
+                "# foo",
                 "host testhost {",
                 "\thardware ethernet 12:34:56:78:90:ab;",
                 "\tfixed-address 1.2.3.4;",
@@ -128,7 +121,8 @@
             ])
 
     def test_subnet (self) :
-        self.assert_obj(dhcp.Subnet(addr.Network("1.2.3.0/24")), [
+        self.assert_obj(dhcp.Subnet(addr.Network("1.2.3.0/24"), comment="bar"), [
+                "# bar",
                 "subnet 1.2.3.0 netmask 255.255.255.0 {",
                 "\toption routers 1.2.3.1;",
                 "}"