terom@247: #!/usr/bin/env python terom@247: terom@247: """ terom@247: Process zonefiles. terom@247: """ terom@247: terom@247: import codecs terom@253: import datetime tero@639: import ipaddr terom@247: import logging terom@270: import math terom@254: import os.path tero@639: import pvl.dns.labels terom@247: terom@247: log = logging.getLogger('pvl.dns.zone') terom@247: terom@247: class ZoneError (Exception) : terom@247: pass terom@247: terom@247: class ZoneLineError (ZoneError) : terom@247: """ terom@247: ZoneLine-related error terom@247: """ terom@247: terom@247: def __init__ (self, line, msg, *args, **kwargs) : terom@247: super(ZoneLineError, self).__init__("%s: %s" % (line, msg.format(*args, **kwargs))) terom@247: tero@639: def zone_quote (field): tero@456: """ tero@639: Quote a value for inclusion into TXT record. tero@456: tero@639: >>> print zone_quote("foo") tero@639: "foo" tero@639: >>> print zone_quote("foo\\bar") tero@639: "foo\\bar" tero@639: >>> print zone_quote("foo \"bar\" quux") tero@639: "foo \"bar\" quux" tero@456: """ tero@456: tero@639: return u'"' + field.replace('\\', '\\\\').replace('"', '\\"') + u'"' tero@456: tero@456: terom@247: class ZoneLine (object) : terom@247: """ terom@250: A line parsed from a zonefile. terom@247: """ terom@247: terom@250: @classmethod tero@639: def load (cls, file) : terom@292: """ tero@639: Load (ZoneDirective or ZoneRecord) items from the given zonefile. tero@639: tero@639: Tracks $ORIGIN and $TTL state for ZoneRecords/ZoneDirectives. terom@292: """ terom@292: tero@639: for line in cls.parse(file) : tero@639: if line.parts[0].startswith('$'): tero@639: # control directive tero@639: line_type = ZoneDirective terom@292: terom@292: else : tero@639: # normal record tero@639: line_type = ZoneRecord terom@292: tero@639: yield line_type.parse(line.parts, tero@639: line = line, tero@639: comment = line.comment, tero@639: ) terom@292: terom@292: @classmethod tero@635: def parse (cls, file, filename=None): terom@250: """ terom@250: Yield ZoneLines lexed from a file. terom@250: """ terom@250: terom@250: if not filename : terom@250: filename = file.name terom@250: terom@250: multiline_start = None terom@250: multiline_parts = None terom@250: terom@250: for lineno, raw_line in enumerate(file) : terom@292: raw_line = raw_line.rstrip('\n') terom@292: terom@250: log.debug("%s:%d: %s", filename, lineno, raw_line) terom@250: terom@250: # capture indent from raw line terom@250: indent = raw_line.startswith(' ') or raw_line.startswith('\t') terom@250: line = raw_line.strip() terom@250: tero@639: if not line: tero@639: # empty line tero@639: continue tero@639: terom@250: # parse comment terom@250: if ';' in line: terom@250: line, comment = line.split(';', 1) terom@250: terom@250: line = line.strip() terom@250: comment = comment.strip() terom@250: else : terom@250: comment = None terom@250: terom@250: log.debug("%s:%d: indent=%r, line=%r, comment=%r", filename, lineno, indent, line, comment) terom@250: terom@250: # split (quoted) fields tero@639: if indent and not multiline_start: tero@639: parts = [''] tero@639: else: tero@639: parts = [] tero@639: terom@250: if '"' in line : terom@250: pre, data, post = line.split('"', 2) tero@639: parts.extend(pre.split() + [data] + post.split()) terom@250: terom@250: else : tero@639: parts.extend(line.split()) terom@250: terom@250: # handle multi-line statements... terom@250: if '(' in parts : terom@250: assert not multiline_start terom@250: terom@292: log.debug("%s:%d: Start of multi-line statement: %s", filename, lineno, line) terom@250: tero@639: multiline_start = (lineno, comment) terom@250: multiline_line = raw_line terom@250: multiline_parts = [] terom@250: terom@250: if multiline_start: terom@292: log.debug("%s:%d: Multi-line statement: %s", filename, lineno, line) terom@250: terom@250: # XXX: some better way to do this terom@250: multiline_parts.extend([part for part in parts if part not in set('()')]) terom@250: multiline_line += raw_line terom@250: terom@250: if ')' in parts : terom@250: assert multiline_start terom@250: terom@292: log.debug("%s:%d: End of multi-line statement: %s", filename, lineno, line) terom@250: tero@639: lineno, comment = multiline_start terom@250: raw_line = multiline_line terom@250: parts = multiline_parts terom@250: terom@250: multiline_start = multiline_line = multiline_parts = None terom@250: terom@250: # parse terom@250: if multiline_start: terom@250: pass terom@250: else: tero@639: yield ZoneLine(parts, tero@639: file = filename, tero@639: lineno = lineno, tero@639: line = raw_line, tero@639: comment = comment, tero@639: ) terom@247: tero@639: def __init__ (self, parts, file=None, lineno=None, line=None, comment=None) : tero@639: """ tero@639: parts : [str] - list of parsed (quoted) fields tero@639: if the line has a leading indent, the first field should be an empty string tero@639: """ terom@247: tero@639: self.parts = parts tero@639: terom@248: # source terom@247: self.file = file terom@247: self.lineno = lineno terom@248: self.line = line tero@639: tero@639: # meta terom@247: self.comment = comment terom@247: terom@316: def __unicode__ (self) : tero@639: return u"{parts}{comment}".format( terom@316: parts = u'\t'.join(self.parts), tero@639: comment = u'\t; ' + self.comment if self.comment is not None else '', terom@316: ) terom@316: terom@316: def __repr__ (self) : terom@247: return "{file}:{lineno}".format(file=self.file, lineno=self.lineno) terom@247: tero@639: terom@323: class ZoneDirective (object) : terom@323: """ terom@323: An $DIRECTIVE in a zonefile. terom@323: """ terom@323: terom@323: # context terom@323: line = None terom@323: origin = None terom@323: terom@323: # fields terom@323: directive = None terom@323: arguments = None terom@323: terom@323: @classmethod tero@639: def parse (cls, parts, **opts) : tero@639: """ tero@639: Parse from ZoneLine.parts tero@639: """ terom@323: tero@639: directive = parts[0][1:].upper() tero@639: arguments = parts[1:] tero@639: tero@639: return cls(directive, arguments, **opts) terom@323: terom@323: @classmethod tero@639: def build (cls, directive, *arguments, **opts): tero@639: """ tero@639: Build directive from optional parts tero@639: """ terom@323: tero@639: return cls(directive, arguments, **opts) tero@639: tero@639: def __init__ (self, directive, arguments, comment=None, line=None, origin=None): tero@639: """ tero@639: directive - uppercase directive name, withtout leading $ tero@639: arguments [str] - list of directive arguments tero@639: comment - optional trailing comment tero@639: line - optional associated ZoneLine tero@639: origin - context origin tero@639: """ tero@639: terom@323: self.directive = directive terom@323: self.arguments = arguments terom@323: tero@639: self.comment = comment terom@323: self.line = line terom@323: self.origin = origin terom@323: tero@639: def __unicode__ (self): terom@323: """ terom@323: Construct a zonefile-format line... terom@323: """ terom@323: tero@639: if self.comment: terom@323: comment = '\t; ' + self.comment terom@323: else : terom@323: comment = '' terom@323: terom@323: return u"${directive}\t{arguments}{comment}".format( terom@323: directive = self.directive, terom@323: arguments = '\t'.join(self.arguments), terom@323: comment = comment, terom@323: ) terom@323: terom@323: def __repr__ (self) : terom@323: return '%s(%s)' % (self.__class__.__name__, ', '.join(repr(arg) for arg in ( tero@639: (self.directive, ) + tuple(self.arguments) terom@323: ))) terom@323: terom@323: tero@639: def process_generate (line, range, parts, **opts) : tero@639: """ tero@639: Parse a tero@639: $GENERATE -[/] lhs [ttl] [class] type rhs [comment] tero@639: directive and yield expanded ZoneRecords. tero@639: tero@639: Raises ZoneLineError tero@639: """ tero@639: tero@639: tero@639: try: tero@639: range = pvl.dns.generate.parse_generate_range(range) tero@639: tero@639: lhs_func = pvl.dns.generate.parse_generate_field(parts[0], line=line) tero@639: body = parts[1:-1] tero@639: rhs_func = pvl.dns.generate.parse_generate_field(parts[-1], line=line) tero@639: tero@639: except ValueError as error: tero@639: raise ZoneLineError(line, "{error}", error=error) tero@639: tero@639: log.info("%s: generate %s: %s ... %s", line, range, lhs_func, rhs_func) tero@639: tero@639: for i in range : tero@639: # build tero@639: parts = [lhs_func(i)] + body + [rhs_func(i)] tero@639: tero@639: log.debug(" %03d: %r", i, parts) tero@639: tero@639: yield ZoneRecord.parse(parts, line=line, **opts) tero@639: tero@639: def process_include (line, origin, include_filename, include_origin=None, **opts): tero@639: """ tero@639: Parse a tero@639: $INCLUDE filename [ origin ] tero@639: directive and yield expanded ZoneRecords. tero@639: tero@639: Raises ZoneLineError. tero@639: """ tero@639: tero@639: path = os.path.join(os.path.dirname(file.name), include_include) tero@639: tero@639: if include_origin: tero@639: origin = pvl.dns.labels.join(include_origin, origin) tero@639: tero@639: for record in ZoneRecord.load(open(path), origin, **opts) : tero@639: yield line, record tero@639: terom@247: class ZoneRecord (object) : terom@247: """ terom@247: A record from a zonefile. terom@247: """ terom@254: terom@247: @classmethod tero@639: def load (cls, file, origin, tero@639: ttl=None, tero@639: ): terom@247: """ tero@639: Yield ZoneRecords from a file. Processes any ZoneDirectives. tero@639: """ tero@639: tero@639: name = None terom@274: tero@639: for line in ZoneLine.parse(file): tero@639: if line.parts[0].startswith('$'): tero@639: directive = ZoneDirective.parse(line.parts, tero@639: origin = origin, tero@639: line = line, tero@639: comment = line.comment, tero@639: ) tero@639: tero@639: log.debug("%s: %s", line, directive) tero@639: tero@639: if directive.directive == 'ORIGIN': tero@639: directive_origin, = directive.arguments tero@639: tero@639: log.info("%s: $ORIGIN %s -> %s", file, origin, directive_origin) tero@639: tero@639: origin = pvl.dns.labels.join(origin, directive_origin) tero@639: tero@639: elif directive.directive == 'TTL' : tero@639: directive_ttl, = directive.arguments tero@639: tero@639: log.info("%s: $TTL %d -> %s", file, ttl, directive_ttl) tero@639: tero@639: ttl = int(directive_ttl) tero@639: tero@639: elif directive.directive == 'GENERATE' : tero@639: for record in process_generate(line, directive.arguments, tero@639: name = name, tero@639: ttl = ttl, tero@639: origin = origin, tero@639: ) : tero@639: yield record tero@639: tero@639: elif directive.directive == 'INCLUDE' : tero@639: for record in process_include(line, origin, tero@639: ttl = ttl, tero@639: *directive.arguments tero@639: ): tero@639: yield record tero@639: tero@639: else : tero@639: log.warn("%s: skip unknown control record: %r", line, directive) tero@639: yield line, None tero@639: tero@639: else: tero@639: record = ZoneRecord.parse(line.parts, tero@639: name = name, tero@639: ttl = ttl, tero@639: comment = line.comment, tero@639: line = line, tero@639: origin = origin, tero@639: ) tero@639: tero@639: log.debug("%s: %s", line, record) tero@639: tero@639: # keep name across lines tero@639: name = record.name tero@639: tero@639: yield record tero@639: tero@639: @classmethod tero@639: def parse (cls, parts, name=None, ttl=None, line=None, **opts) : tero@639: """ tero@639: Build a ZoneRecord from ZoneLine.parts. tero@639: tero@639: name - context for name, if continuing previous line tero@639: ttl - context for ttl, if using $TTL terom@274: terom@274: Return: (name, ZoneRecord) terom@247: """ terom@247: tero@639: parts = list(parts) terom@247: tero@639: # first field is either name or leading whitespace tero@639: leading = parts.pop(0) terom@247: tero@639: if leading: tero@639: name = leading tero@639: terom@247: if len(parts) < 2 : tero@639: raise ZoneLineError(line, "Too few parts to parse: {parts!r}", parts=parts) terom@247: terom@247: # parse ttl/cls/type terom@254: _cls = None terom@247: terom@247: if parts and parts[0][0].isdigit() : tero@639: ttl = int(parts.pop(0)) terom@247: terom@247: if parts and parts[0].upper() in ('IN', 'CH') : tero@639: _cls = parts.pop(0).upper() terom@247: terom@247: # always have type tero@639: type = parts.pop(0).upper() terom@247: terom@247: # remaining parts are data terom@247: data = parts terom@247: terom@247: log.debug(" ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data) terom@247: tero@639: return cls(name, ttl, _cls, type, data, line=line, **opts) terom@252: terom@252: @classmethod tero@639: def build (_cls, name, type, *data, **opts): tero@639: """ tero@639: Simple interface to build ZoneRecord from required parts. All optional fields must be given as keyword arguments. terom@247: tero@639: Normalizes all fields to strs. tero@639: """ tero@639: tero@639: # keyword-only arguments tero@639: ttl = opts.pop('ttl', None) tero@639: cls = opts.pop('cls', None) terom@308: tero@639: if name: tero@639: name = str(name) tero@639: else: tero@639: name = None tero@639: tero@639: if ttl or ttl == 0: tero@639: ttl = int(ttl) tero@639: else: tero@639: ttl = None tero@639: tero@639: if cls: tero@639: cls = cls.upper() tero@639: else: tero@639: cls = None tero@639: tero@639: type = type.upper() tero@639: tero@639: data = [unicode(item) for item in data] tero@639: tero@639: return _cls(name, ttl, cls, type, data, **opts) terom@267: terom@267: @classmethod tero@639: def A (_cls, name, ip4, **opts): tero@639: """ tero@639: Build from ipaddr.IPv4Address. tero@639: """ tero@639: tero@639: return _cls.build(name, 'A', ipaddr.IPv4Address(ip4), **opts) terom@267: terom@267: @classmethod tero@639: def AAAA (_cls, name, ip6, **opts): tero@639: """ tero@639: Build from ipaddr.IPv6Address. tero@639: """ tero@639: tero@639: return _cls.build(name, 'AAAA', ipaddr.IPv6Address(ip6), **opts) terom@247: terom@247: @classmethod tero@639: def CNAME (_cls, name, alias, **opts): tero@639: return _cls.build(name, 'CNAME', alias, **opts) terom@247: terom@247: @classmethod tero@639: def TXT (_cls, name, text, **opts): tero@639: """ tero@639: Build from quoted (unicode) value. tero@639: """ terom@247: tero@639: return _cls.build(name, 'TXT', zone_quote(unicode(text)), **opts) tero@639: tero@639: @classmethod tero@639: def PTR (_cls, name, ptr, **opts): tero@639: return _cls.build(name, 'PTR', ptr, **opts) tero@639: tero@639: @classmethod tero@639: def MX (_cls, name, priority, mx, **opts): tero@639: """ tero@639: priority : int tero@639: mx : str - hostname tero@639: """ tero@639: tero@639: return _cls.build(name, 'MX', int(priority), str(mx), **opts) tero@639: tero@639: def __init__ (self, name, ttl, cls, type, data, comment=None, origin=None, line=None): tero@639: """ tero@639: Using strict field ordering. tero@639: tero@639: name - local label with respect to $ORIGIN tero@639: may also be @ to refer to $ORIGIN tero@639: ttl - int TTL for record, default to implict $TTL tero@639: cls - uppercase class for record, default to IN tero@639: type - uppercase type for record, required tero@639: data [...] - list of data fields, interpretation varies by type tero@639: comment - optional comment to include in zone tero@639: origin - track implicit $ORIGIN or XXX: previous record state tero@639: line - associated ZoneLine tero@639: """ tero@639: terom@247: self.name = name tero@639: self.ttl = ttl tero@639: self.cls = cls terom@247: self.type = type terom@247: self.data = data terom@247: tero@639: self.comment = comment tero@639: self.origin = origin terom@294: self.line = line terom@247: terom@247: def __unicode__ (self) : terom@247: """ terom@247: Construct a zonefile-format line..." terom@247: """ terom@247: terom@247: if self.comment : terom@378: comment = '\t; ' + self.comment terom@247: else : terom@247: comment = '' terom@247: terom@247: return u"{name:25} {ttl:4} {cls:2} {type:5} {data}{comment}".format( tero@639: name = '' if self.name is None else self.name, tero@639: ttl = '' if self.ttl is None else self.ttl, tero@639: cls = '' if self.cls is None else self.cls, terom@247: type = self.type, terom@247: data = ' '.join(unicode(data) for data in self.data), terom@247: comment = comment, terom@247: ) terom@247: terom@247: def __repr__ (self) : terom@247: return '%s(%s)' % (self.__class__.__name__, ', '.join(repr(arg) for arg in ( tero@639: self.name, self.ttl, self.cls, self.type, self.data terom@247: ))) terom@247: terom@252: class SOA (ZoneRecord) : terom@252: @classmethod terom@294: def build (cls, line, name, ttl, _cls, type, data, **opts) : terom@252: assert name == '@' terom@252: terom@294: return cls(*data, terom@252: ttl = ttl, terom@252: cls = cls, terom@252: line = line, terom@252: **opts terom@252: ) terom@252: terom@252: def __init__ (self, master, contact, serial, refresh, retry, expire, nxttl, **opts) : terom@252: super(SOA, self).__init__('@', 'SOA', terom@252: [master, contact, serial, refresh, retry, expire, nxttl], terom@252: **opts terom@252: ) terom@252: terom@252: self.master = master terom@252: self.contact = contact terom@252: self.serial = serial terom@252: self.refresh = refresh terom@252: self.retry = retry terom@252: self.expire = expire terom@252: self.nxttl = nxttl terom@252: