bin/process-zone
changeset 558 840092ee4d97
parent 548 3d35d0eef197
child 603 b58236f9ea7b
equal deleted inserted replaced
557:d2e187c1f548 558:840092ee4d97
    82     parser.add_option('--reverse-domain',       metavar='DOMAIN',
    82     parser.add_option('--reverse-domain',       metavar='DOMAIN',
    83             help="Domain to use for hosts in reverse zone")
    83             help="Domain to use for hosts in reverse zone")
    84 
    84 
    85     parser.add_option('--reverse-zone',         metavar='NET',
    85     parser.add_option('--reverse-zone',         metavar='NET',
    86             help="Generate forward zone for given subnet (x.z.y)")
    86             help="Generate forward zone for given subnet (x.z.y)")
       
    87 
       
    88     # 
       
    89     parser.add_option('--doctest',              action='store_true',
       
    90             help="Run module doctests")
    87 
    91 
    88     # defaults
    92     # defaults
    89     parser.set_defaults(
    93     parser.set_defaults(
    90         loglevel            = logging.WARN,
    94         loglevel            = logging.WARN,
    91 
    95 
   103         level   = options.loglevel,
   107         level   = options.loglevel,
   104     )
   108     )
   105 
   109 
   106     return options, args
   110     return options, args
   107 
   111 
       
   112 class ZoneError (Exception) :
       
   113     pass
       
   114 
       
   115 class ZoneLineError (ZoneError) :
       
   116     """
       
   117         ZoneLine-related error
       
   118     """
       
   119 
       
   120     def __init__ (self, line, msg, *args, **kwargs) :
       
   121         super(ZoneLineError, self).__init__("%s: %s" % (line, msg.format(*args, **kwargs)))
       
   122 
   108 class ZoneLine (object) :
   123 class ZoneLine (object) :
   109     """
   124     """
   110         A line in a zonefile.
   125         A line in a zonefile.
   111     """
   126     """
   112 
   127 
   134 
   149 
   135         ts = None
   150         ts = None
   136 
   151 
   137         if line_timestamp_prefix :
   152         if line_timestamp_prefix :
   138             if ': ' not in line :
   153             if ': ' not in line :
   139                 raise Exception("Missing timestamp prefix on line: %s:%d: %s" % (file, lineno, line))
   154                 raise ZoneError("%s:%d: Missing timestamp prefix: %s" % (file, lineno, line))
   140 
   155 
   141             # split prefix
   156             # split prefix
   142             prefix, line = line.split(': ', 1)
   157             prefix, line = line.split(': ', 1)
   143 
   158 
   144             # parse it out
   159             # parse it out
   200     """
   215     """
   201 
   216 
   202     # the underlying line
   217     # the underlying line
   203     line = None
   218     line = None
   204 
   219 
       
   220     # possible $ORIGIN context
       
   221     origin = None
       
   222 
   205     # record fields
   223     # record fields
   206     name = None
   224     name = None
   207     type = None
   225     type = None
   208 
   226 
   209     # list of data fields
   227     # list of data fields
   212     # optional
   230     # optional
   213     ttl = None
   231     ttl = None
   214     cls = None
   232     cls = None
   215 
   233 
   216     @classmethod
   234     @classmethod
   217     def parse (cls, line) :
   235     def parse (cls, line, parts=None, origin=None) :
   218         """
   236         """
   219             Parse from ZoneLine. Returns None if there is no record on the line..
   237             Parse from ZoneLine. Returns None if there is no record on the line..
   220         """
   238         """
   221 
   239 
   222         if not line.parts :
   240         if parts is None :
       
   241             parts = list(line.parts)
       
   242 
       
   243         if not parts :
   223             # skip
   244             # skip
   224             return
   245             return
   225         
   246         
   226         # consume parts
       
   227         parts = list(line.parts)
       
   228 
       
   229         # indented lines don't have name
   247         # indented lines don't have name
   230         if line.indent :
   248         if line.indent :
   231             name = None
   249             name = None
   232 
   250 
   233         else :
   251         else :
   234             name = parts.pop(0)
   252             name = parts.pop(0)
   235         
   253         
   236         log.debug("  name=%r", name)
   254         log.debug("  name=%r, origin=%r", name, origin)
       
   255 
       
   256         if len(parts) < 2 :
       
   257             raise ZoneLineError(line, "Too few parts to parse: {0!r}", line.data)
   237 
   258 
   238         # parse ttl/cls/type
   259         # parse ttl/cls/type
   239         ttl = _cls = None
   260         ttl = _cls = None
   240 
   261 
   241         if parts and parts[0][0].isdigit() :
   262         if parts and parts[0][0].isdigit() :
   251         data = parts
   272         data = parts
   252 
   273 
   253         log.debug("  ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data)
   274         log.debug("  ttl=%r, cls=%r, type=%r, data=%r", ttl, _cls, type, data)
   254 
   275 
   255         return cls(name, type, data,
   276         return cls(name, type, data,
       
   277             origin  = origin,
   256             ttl     = ttl,
   278             ttl     = ttl,
   257             cls     = _cls,
   279             cls     = _cls,
   258             line    = line,
   280             line    = line,
   259         )
   281         )
   260 
   282 
   261     def __init__ (self, name, type, data, ttl=None, cls=None, line=None, comment=None) :
   283     def __init__ (self, name, type, data, origin=None, ttl=None, cls=None, line=None, comment=None) :
   262         self.name = name
   284         self.name = name
   263         self.type = type
   285         self.type = type
   264         self.data = data
   286         self.data = data
   265         
   287         
   266         self.ttl = ttl
   288         self.ttl = ttl
   267         self.cls = cls
   289         self.cls = cls
   268         
   290         
       
   291         self.origin = origin
   269         self.line = line
   292         self.line = line
   270 
   293 
   271         # XXX: within line
   294         # XXX: within line
   272         self._comment = comment
   295         self._comment = comment
   273 
   296 
   290                 data    = ' '.join(unicode(data) for data in self.data),
   313                 data    = ' '.join(unicode(data) for data in self.data),
   291                 comment = comment,
   314                 comment = comment,
   292         )
   315         )
   293 
   316 
   294     def __str__ (self) :
   317     def __str__ (self) :
   295         return ' '.join((self.name, self.type, ' '.join(self.data)))
   318         return ' '.join((self.name or '', self.type, ' '.join(self.data)))
   296 
   319 
   297 class TXTRecord (ZoneRecord) :
   320 class TXTRecord (ZoneRecord) :
   298     """
   321     """
   299         TXT record.
   322         TXT record.
   300     """
   323     """
   303         return super(TXTRecord, self).__init__(name, 'TXT', 
   326         return super(TXTRecord, self).__init__(name, 'TXT', 
   304             [u'"{0}"'.format(text.replace('"', '\\"'))], 
   327             [u'"{0}"'.format(text.replace('"', '\\"'))], 
   305             **opts
   328             **opts
   306         )
   329         )
   307 
   330 
   308 def parse_record (path, lineno, line, **opts) :
   331 class OffsetValue (object) :
   309     """
   332     def __init__ (self, value) :
   310         Parse (name, ttl, type, data, comment) from bind zonefile.
   333         self.value = value
   311 
   334 
   312         Returns None for empty/comment lines.
   335     def __getitem__ (self, offset) :
   313     """
   336         value = self.value + offset
   314 
   337 
   315     # line
   338         #log.debug("OffsetValue: %d[%d] -> %d", self.value, offset, value)
   316     line = ZoneLine.parse(path, lineno, line, **opts)
   339 
   317     record = ZoneRecord.parse(line)
   340         return value
   318 
   341 
   319     if record :
   342 def parse_generate_field (line, field) :
   320         return record
   343     """
   321 
   344         Parse a $GENERATE lhs/rhs field:
   322 def parse_zone_records (file, **opts) :
   345             $
       
   346             ${<offset>[,<width>[,<base>]]}
       
   347             \$
       
   348             $$
       
   349 
       
   350         Returns a wrapper that builds the field-value when called with the index.
       
   351         
       
   352         >>> parse_generate_field(None, "foo")(1)
       
   353         'foo'
       
   354         >>> parse_generate_field(None, "foo-$")(1)
       
   355         'foo-1'
       
   356         >>> parse_generate_field(None, "foo-$$")(1)
       
   357         'foo-$'
       
   358         >>> parse_generate_field(None, "\$")(1)
       
   359         '$'
       
   360         >>> parse_generate_field(None, "10.0.0.${100}")(1)
       
   361         '10.0.0.101'
       
   362         >>> parse_generate_field(None, "foo-${0,2,d}")(1)
       
   363         'foo-01'
       
   364 
       
   365     """
       
   366 
       
   367     input = field
       
   368     expr = []
       
   369 
       
   370     while '$' in field :
       
   371         # defaults
       
   372         offset = 0
       
   373         width = 0
       
   374         base = 'd'
       
   375         escape = False
       
   376 
       
   377         # different forms
       
   378         if '${' in field :
       
   379             pre, body = field.split('${', 1)
       
   380             body, post = body.split('}', 1)
       
   381 
       
   382             # parse body
       
   383             parts = body.split(',')
       
   384 
       
   385             # offset
       
   386             offset = int(parts.pop(0))
       
   387 
       
   388             # width
       
   389             if parts :
       
   390                 width = int(parts.pop(0))
       
   391 
       
   392             # base
       
   393             if parts :
       
   394                 base = parts.pop(0)
       
   395             
       
   396             if parts:
       
   397                 # fail
       
   398                 raise ZoneLineError(line, "extra data in ${...} body: {0!r}", parts)
       
   399 
       
   400         elif '$$' in field :
       
   401             pre, post = field.split('$$', 1)
       
   402             escape = True
       
   403 
       
   404         elif '\\$' in field :
       
   405             pre, post = field.split('\\$', 1)
       
   406             escape = True
       
   407 
       
   408         else :
       
   409             pre, post = field.split('$', 1)
       
   410         
       
   411         expr.append(pre)
       
   412 
       
   413         if escape :
       
   414             expr.append('$')
       
   415 
       
   416         else :
       
   417             # meta-format
       
   418             fmt = '{value[%d]:0%d%s}' % (offset, width, base)
       
   419 
       
   420             log.debug("field=%r -> pre=%r, fmt=%r, post=%r", field, expr, fmt, post)
       
   421 
       
   422             expr.append(fmt)
       
   423 
       
   424         field = post
       
   425 
       
   426     # final
       
   427     if field :
       
   428         expr.append(field)
       
   429     
       
   430     # combine
       
   431     expr = ''.join(expr)
       
   432 
       
   433     log.debug("%s: %s", input, expr)
       
   434 
       
   435     # processed
       
   436     def value_func (value) :
       
   437         # magic wrapper to implement offsets
       
   438         return expr.format(value=OffsetValue(value))
       
   439     
       
   440     return value_func
       
   441 
       
   442 def process_generate (line, origin, parts) :
       
   443     """
       
   444         Process a 
       
   445             $GENERATE <start>-<stop>[/<step>] lhs [ttl] [class] type rhs [comment]
       
   446         directive into a series of ZoneResource's.
       
   447     """
       
   448 
       
   449     range = parts.pop(0)
       
   450 
       
   451     # parse range
       
   452     if '/' in range :
       
   453         range, step = range.split('/')
       
   454         step = int(step)
       
   455     else :
       
   456         step = 1
       
   457 
       
   458     start, stop = range.split('-')
       
   459     start = int(start)
       
   460     stop = int(stop)
       
   461 
       
   462     log.debug("  range: start=%r, stop=%r, step=%r", start, stop, step)
       
   463 
       
   464     # inclusive
       
   465     range = xrange(start, stop + 1, step)
       
   466 
       
   467     lhs_func = parse_generate_field(line, parts.pop(0))
       
   468     rhs_func = parse_generate_field(line, parts.pop(-1))
       
   469     body = parts
       
   470 
       
   471     for i in range :
       
   472         # build
       
   473         parts = [lhs_func(i)] + body + [rhs_func(i)]
       
   474 
       
   475         log.debug(" %03d: %r", i, parts)
       
   476 
       
   477         # parse
       
   478         yield ZoneRecord.parse(line, parts=parts, origin=origin)
       
   479 
       
   480 def parse_zone_records (file, origin=None, **opts) :
   323     """
   481     """
   324         Parse ZoneRecord items from the given zonefile, ignoring non-record lines.
   482         Parse ZoneRecord items from the given zonefile, ignoring non-record lines.
   325     """
   483     """
   326     
   484 
   327     for lineno, line in enumerate(file) :
   485     ttl = None
   328         record = parse_record(file.name, lineno, line, **opts)
   486 
       
   487     skip_multiline = False
       
   488     
       
   489     for lineno, raw_line in enumerate(file) :
       
   490         # parse comment
       
   491         if ';' in raw_line :
       
   492             line, comment = raw_line.split(';', 1)
       
   493         else :
       
   494             line = raw_line
       
   495             comment = None
       
   496 
       
   497         # XXX: handle multi-line statements...
       
   498         # start
       
   499         if '(' in line :
       
   500             skip_multiline = True
       
   501             
       
   502             log.warn("%s:%d: Start of multi-line statement: %s", file.name, lineno, raw_line)
       
   503 
       
   504         # end?
       
   505         if ')' in line :
       
   506             skip_multiline = False
       
   507             
       
   508             log.warn("%s:%d: End of multi-line statement: %s", file.name, lineno, raw_line)
       
   509             
       
   510             continue
       
   511 
       
   512         elif skip_multiline :
       
   513             log.warn("%s:%d: Multi-line statement: %s", file.name, lineno, raw_line)
       
   514 
       
   515             continue
       
   516         
       
   517         # parse
       
   518         line = ZoneLine.parse(file.name, lineno, raw_line, **opts)
       
   519 
       
   520         if not line.data :
       
   521             log.debug("%s: skip empty line: %s", line, raw_line)
       
   522 
       
   523             continue
       
   524 
       
   525         elif line.data.startswith('$') :
       
   526             # control record
       
   527             type = line.parts[0]
       
   528 
       
   529             if type == '$ORIGIN':
       
   530                 # update
       
   531                 origin = line.parts[1]
       
   532                 
       
   533                 log.info("%s: origin: %s", line, origin)
       
   534             
       
   535             elif type == '$GENERATE':
       
   536                 # process...
       
   537                 log.info("%s: generate: %s", line, line.parts)
       
   538 
       
   539                 for record in process_generate(line, origin, line.parts[1:]) :
       
   540                     yield record
       
   541 
       
   542             else :
       
   543                 log.warning("%s: skip control record: %s", line, line.data)
       
   544             
       
   545             # XXX: passthrough!
       
   546             continue
       
   547 
       
   548         # normal record?
       
   549         record = ZoneRecord.parse(line, origin=origin)
   329 
   550 
   330         if record :
   551         if record :
   331             yield record
   552             yield record
   332 
   553 
   333 def check_zone_hosts (zone, whitelist=None) :
   554         else :
       
   555             # unknown
       
   556             log.warning("%s: skip unknown line: %s", line, line.data)
       
   557 
       
   558 def check_zone_hosts (zone, whitelist=None, whitelist_types=set(['TXT'])) :
   334     """
   559     """
   335         Parse host/IP pairs from the zone, and verify that they are unique.
   560         Parse host/IP pairs from the zone, and verify that they are unique.
   336 
   561 
   337         As an exception, names listed in the given whitelist may have multiple IPs.
   562         As an exception, names listed in the given whitelist may have multiple IPs.
   338     """
   563     """
   340     by_name = {}
   565     by_name = {}
   341     by_ip = {}
   566     by_ip = {}
   342 
   567 
   343     fail = None
   568     fail = None
   344 
   569 
       
   570     last_name = None
       
   571 
   345     for r in zone :
   572     for r in zone :
   346         name = r.name
   573         name = r.name or last_name
       
   574 
       
   575         name = (r.origin, name)
   347 
   576 
   348         # name
   577         # name
   349         if name not in by_name :
   578         if r.type not in whitelist_types :
   350             by_name[name] = r
   579             if name not in by_name :
   351 
   580                 by_name[name] = r
   352         elif r.name in whitelist :
   581 
   353             log.debug("Duplicate whitelist entry: %s", r)
   582             elif r.name in whitelist :
   354 
   583                 log.debug("Duplicate whitelist entry: %s", r)
   355         else :
   584 
   356             # fail!
   585             else :
   357             log.warn("%s: Duplicate name: %s <-> %s", r.line, r, by_name[name])
   586                 # fail!
   358             fail = True
   587                 log.warn("%s: Duplicate name: %s <-> %s", r.line, r, by_name[name])
       
   588                 fail = True
   359 
   589 
   360         # ip
   590         # ip
   361         if r.type == 'A' :
   591         if r.type == 'A' :
   362             ip, = r.data
   592             ip, = r.data
   363 
   593 
   427         assert 0 <= octet <= 255
   657         assert 0 <= octet <= 255
   428 
   658 
   429     return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa'])
   659     return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa'])
   430 
   660 
   431 def fqdn (*parts) :
   661 def fqdn (*parts) :
   432     return '.'.join(parts) + '.'
   662     fqdn = '.'.join(parts)
   433 
   663     
       
   664     # we may be given an fqdn in parts
       
   665     if not fqdn.endswith('.') :
       
   666         fqdn += '.'
       
   667     
       
   668     return fqdn
   434 
   669 
   435 def process_zone_reverse (zone, origin, domain) :
   670 def process_zone_reverse (zone, origin, domain) :
   436     """
   671     """
   437         Process zone data -> reverse zone data.
   672         Process zone data -> reverse zone data.
   438     """
   673     """
   453         else :
   688         else :
   454             log.warning("Reverse does not match zone origin, skipping: (%s) -> %s <-> %s", ip, reverse, origin)
   689             log.warning("Reverse does not match zone origin, skipping: (%s) -> %s <-> %s", ip, reverse, origin)
   455             continue
   690             continue
   456 
   691 
   457         # domain to use
   692         # domain to use
   458         host_domain = domain
   693         host_domain = r.origin or domain
   459         host_fqdn = fqdn(r.name, domain)
   694         host_fqdn = fqdn(r.name, host_domain)
   460 
   695 
   461         yield ZoneRecord(reverse, 'PTR', [host_fqdn])
   696         yield ZoneRecord(reverse, 'PTR', [host_fqdn])
   462 
   697 
   463 def write_zone_records (file, zone) :
   698 def write_zone_records (file, zone) :
   464     for r in zone :
   699     for r in zone :
   486 def main (argv) :
   721 def main (argv) :
   487     global options
   722     global options
   488     
   723     
   489     options, args = parse_options(argv)
   724     options, args = parse_options(argv)
   490 
   725 
       
   726     if options.doctest :
       
   727         import doctest
       
   728         fail, total = doctest.testmod()
       
   729         return fail
       
   730 
   491     if args :
   731     if args :
   492         # open files
   732         # open files
   493         input_files = [open_file(path, 'r', options.input_charset) for path in args]
   733         input_files = [open_file(path, 'r', options.input_charset) for path in args]
   494 
   734 
   495     else :
   735     else :