bin/process-zone
changeset 0 f0ac5a96fb19
child 4 3a2221124592
equal deleted inserted replaced
-1:000000000000 0:f0ac5a96fb19
       
     1 #!/usr/bin/env python
       
     2 
       
     3 """
       
     4     Process zonefiles.
       
     5 """
       
     6 
       
     7 __version__ = '0.0.1-dev'
       
     8 
       
     9 import optparse
       
    10 import codecs
       
    11 import logging
       
    12 
       
    13 log = logging.getLogger()
       
    14 
       
    15 # command-line options, global state
       
    16 options = None
       
    17 
       
    18 def parse_options (argv) :
       
    19     """
       
    20         Parse command-line arguments.
       
    21     """
       
    22 
       
    23     parser = optparse.OptionParser(
       
    24             prog        = argv[0],
       
    25             usage       = '%prog: [options]',
       
    26             version     = __version__,
       
    27 
       
    28             # module docstring
       
    29             description = __doc__,
       
    30     )
       
    31 
       
    32     # logging
       
    33     general = optparse.OptionGroup(parser, "General Options")
       
    34 
       
    35     general.add_option('-q', '--quiet',     dest='loglevel', action='store_const', const=logging.WARNING, help="Less output")
       
    36     general.add_option('-v', '--verbose',   dest='loglevel', action='store_const', const=logging.INFO,  help="More output")
       
    37     general.add_option('-D', '--debug',     dest='loglevel', action='store_const', const=logging.DEBUG, help="Even more output")
       
    38 
       
    39     parser.add_option_group(general)
       
    40 
       
    41     parser.add_option('-c', '--input-charset',  metavar='CHARSET',  default='utf-8', 
       
    42             help="Encoding used for input files")
       
    43 
       
    44     parser.add_option('-o', '--output',         metavar='FILE',     default='-',
       
    45             help="Write to output file; default stdout")
       
    46 
       
    47     parser.add_option('--output-charset',       metavar='CHARSET',  default='utf-8', 
       
    48             help="Encoding used for output files")
       
    49 
       
    50     parser.add_option('--serial',               metavar='FILE',
       
    51             help="Read/update serial from given .serial file")
       
    52 
       
    53     parser.add_option('--forward-zone',         action='store_true', 
       
    54             help="Generate forward zone")
       
    55 
       
    56     parser.add_option('--forward-txt',          action='store_true',
       
    57             help="Generate TXT records for forward zone")
       
    58 
       
    59     parser.add_option('--forward-mx',           metavar='MX',
       
    60             help="Generate MX records for forward zone")
       
    61 
       
    62     parser.add_option('--reverse-domain',       metavar='DOMAIN',
       
    63             help="Domain to use for hosts in reverse zone")
       
    64 
       
    65     parser.add_option('--reverse-zone',         metavar='NET',
       
    66             help="Generate forward zone for given subnet (x.z.y)")
       
    67 
       
    68 
       
    69 #    parser.add_option('--output-forward',       metavar='FILE', default=False, help="Hosts output file")
       
    70 #    parser.add_option('--output-reverse',       metavar='FILE', default=False, help="Reverse-hosts output file")
       
    71 #    parser.add_option('--forward-info',         action='store_true', help="Include additional TXT records in forward zone output")
       
    72 
       
    73 #    parser.add_option('--reverse-zone',         metavar='DOMAIN', help="Zone origin used for reverse zone")
       
    74 
       
    75     # defaults
       
    76     parser.set_defaults(
       
    77         loglevel            = logging.INFO,
       
    78     )
       
    79     
       
    80     # parse
       
    81     options, args = parser.parse_args(argv[1:])
       
    82 
       
    83     # configure
       
    84     logging.basicConfig(
       
    85         format  = '%(processName)s: %(name)s: %(levelname)s %(funcName)s : %(message)s',
       
    86         level   = options.loglevel,
       
    87     )
       
    88 
       
    89     return options, args
       
    90 
       
    91 def parse_record (line) :
       
    92     """
       
    93         Parse (name, ttl, type, data, comment) from bind zonefile.
       
    94 
       
    95         Returns None for empty/comment lines.
       
    96     """
       
    97     
       
    98     # was line indented?
       
    99     indent = line.startswith(' ') or line.startswith('\t')
       
   100     
       
   101     # strip
       
   102     line = line.strip()
       
   103 
       
   104     if not line or line.startswith(';') :
       
   105         # skip
       
   106         return
       
   107     
       
   108     #log.debug("line=%r", line)
       
   109 
       
   110     # parse comment out
       
   111     parts = line.split(';', 1)
       
   112 
       
   113     if ';' in line :
       
   114         data, comment = line.split(';', 1)
       
   115 
       
   116         line = data.rstrip()
       
   117         comment = comment.strip()
       
   118 
       
   119     else :
       
   120         line = line.rstrip()
       
   121         comment = None
       
   122     
       
   123     #log.debug("line=%r, comment=%r", line, comment)
       
   124 
       
   125     # parse data out?
       
   126     if '"' in line :
       
   127         line, data, end = line.split('"')
       
   128         parts = line.split()
       
   129        
       
   130     else :
       
   131         parts = line.split()
       
   132         data = parts.pop(-1)
       
   133 
       
   134     #log.debug("parts=%r, data=%r", parts, data)
       
   135 
       
   136     # indented lines don't have name
       
   137     if indent :
       
   138         name = None
       
   139 
       
   140     else :
       
   141         name = parts.pop(0)
       
   142     
       
   143     #log.debug("name=%r", name)
       
   144 
       
   145     # parse ttl/cls/type
       
   146     ttl = cls = None
       
   147 
       
   148     type = parts.pop(-1)
       
   149 
       
   150     if parts and parts[0][0].isdigit() :
       
   151         ttl = parts.pop(0)
       
   152 
       
   153     if parts :
       
   154         cls = parts.pop(0)
       
   155 
       
   156     #log.debug("ttl=%r, cls=%r, parts=%r", ttl, cls, parts)
       
   157 
       
   158     if parts :
       
   159         raise Exception("Extra data: %r" % (line))
       
   160 
       
   161     return name, ttl, type, data, comment
       
   162 
       
   163 def parse_zone (file) :
       
   164     """
       
   165         Parse
       
   166             (name, ttl, type, data, comment) 
       
   167         data from zonefile.
       
   168     """
       
   169     
       
   170     for lineno, line in enumerate(file) :
       
   171         data = parse_record(line)
       
   172 
       
   173         if data :
       
   174             yield data
       
   175 
       
   176 def process_zone_forwards (zone, txt=False, mx=False) :
       
   177     """
       
   178         Process zone data -> forward zone data.
       
   179     """
       
   180 
       
   181     for name, ttl, type, data, comment in zone :
       
   182         yield name, ttl, type, data
       
   183 
       
   184         if type == 'A' :
       
   185             if txt and comment :
       
   186                 # name
       
   187                 yield None, ttl, 'TXT', u'"{0}"'.format(comment)
       
   188             
       
   189             # XXX: RP, do we need it?
       
   190 
       
   191             if mx :
       
   192                 # XXX: is this a good idea?
       
   193                 yield None, ttl, 'MX', '10 {mx}'.format(mx=mx)
       
   194 
       
   195 def reverse_addr (ip) :
       
   196     """
       
   197         Return in-addr.arpa reverse for given IPv4 IP.
       
   198     """
       
   199     
       
   200     # parse
       
   201     octets = tuple(int(part) for part in ip.split('.'))
       
   202 
       
   203     for octet in octets :
       
   204         assert 0 <= octet <= 255
       
   205 
       
   206     return '.'.join([str(octet) for octet in reversed(octets)] + ['in-addr', 'arpa'])
       
   207 
       
   208 def fqdn (*parts) :
       
   209     return '.'.join(parts) + '.'
       
   210 
       
   211 
       
   212 def process_zone_reverse (zone, origin, domain) :
       
   213     """
       
   214         Process zone data -> reverse zone data.
       
   215     """
       
   216 
       
   217     for name, ttl, type, data, comment in zone :
       
   218         if type != 'A' :
       
   219             continue
       
   220 
       
   221         ip = data
       
   222 
       
   223         # generate reverse-addr
       
   224         reverse = reverse_addr(ip)
       
   225 
       
   226         # verify
       
   227         if zone and reverse.endswith(origin) :
       
   228             reverse = reverse[:-(len(origin) + 1)]
       
   229 
       
   230         else :
       
   231             log.warning("Reverse does not match zone origin: (%s) -> %s <-> %s", ip, reverse, origin)
       
   232             continue
       
   233 
       
   234         # domain to use
       
   235         host_domain = domain
       
   236         host_fqdn = fqdn(name, domain)
       
   237 
       
   238         yield reverse, 'PTR', host_fqdn
       
   239 
       
   240 def build_zone (zone) :
       
   241     for item in zone :
       
   242         ttl = cls = comment = None
       
   243 
       
   244         if len(item) == 3 :
       
   245             name, type, data = item
       
   246 
       
   247         elif len(item) == 4 :
       
   248             name, ttl, type, data = item
       
   249 
       
   250         elif len(item) == 5 :
       
   251             name, ttl, type, data, comment = item
       
   252 
       
   253         else :
       
   254             raise Exception("Weird zone entry: {0}".format(item))
       
   255 
       
   256         if not name :
       
   257             name = ''
       
   258 
       
   259         if not ttl :
       
   260             ttl = ''
       
   261         
       
   262         if not cls :
       
   263             cls = ''
       
   264 
       
   265         if comment :
       
   266             comment = '\t;' + comment
       
   267         else :
       
   268             comment = ''
       
   269         
       
   270         yield u"{name:25} {ttl:4} {cls:2} {type:5} {data}{comment}".format(name=name, ttl=ttl, cls=cls, type=type, data=data, comment=comment)
       
   271 
       
   272 def write_zone (file, zone) :
       
   273     for line in build_zone(zone) :
       
   274         file.write(unicode(line + '\n'))
       
   275 
       
   276 def open_file (path, mode, charset) :
       
   277     """
       
   278         Open unicode-enabled file from path, with - using stdio.
       
   279     """
       
   280 
       
   281     if path == '-' :
       
   282         # use stdin/out based on mode
       
   283         stream, func = {
       
   284             'r':    (sys.stdin, codecs.getreader),
       
   285             'w':    (sys.stdout, codecs.getwriter),
       
   286         }[mode[0]]
       
   287 
       
   288         # wrap
       
   289         return func(charset)(stream)
       
   290 
       
   291     else :
       
   292         # open
       
   293         return codecs.open(path, mode, charset)
       
   294 
       
   295 def main (argv) :
       
   296     global options
       
   297     
       
   298     options, args = parse_options(argv)
       
   299 
       
   300     if args :
       
   301         # open files
       
   302         input_files = [open_file(path, 'r', options.input_charset) for path in args]
       
   303 
       
   304     else :
       
   305         # default to stdout
       
   306         input_files = [open_file('-', 'r', options.input_charset)]
       
   307    
       
   308     # process zone data
       
   309     zone = []
       
   310 
       
   311     for file in input_files :
       
   312         log.info("Reading zone: %s", file)
       
   313 
       
   314         zone += list(parse_zone(file))
       
   315 
       
   316     # output file
       
   317     output = open_file(options.output, 'w', options.output_charset)
       
   318 
       
   319     if options.forward_zone :
       
   320         log.info("Write forward zone: %s", output)
       
   321 
       
   322         zone = list(process_zone_forwards(zone, txt=options.forward_txt, mx=options.forward_mx))
       
   323 
       
   324     elif options.reverse_zone :
       
   325         origin = reverse_addr(options.reverse_zone)
       
   326         domain = options.reverse_domain
       
   327 
       
   328         if not domain :
       
   329             log.error("--reverse-zone requires --reverse-domain")
       
   330             return 1
       
   331 
       
   332         zone = list(process_zone_reverse(zone, origin=origin, domain=domain))
       
   333 
       
   334     else :
       
   335         log.warn("Nothing to do")
       
   336 
       
   337     write_zone(output, zone)
       
   338 
       
   339     return 0
       
   340 
       
   341 if __name__ == '__main__':
       
   342     import sys
       
   343 
       
   344     sys.exit(main(sys.argv))