terom@233: #!/usr/bin/env python terom@233: terom@233: """ terom@247: Process bind zonefiles. terom@258: terom@258: Takes a zonefile as input, and gives a zonefile as output. terom@233: """ terom@233: terom@247: import pvl.args terom@247: import pvl.dns.zone terom@247: from pvl.dns import __version__ terom@247: from pvl.dns.zone import ZoneRecord, reverse_ipv4, reverse_ipv6, fqdn terom@233: terom@316: import optparse terom@316: import os.path terom@247: import logging; log = logging.getLogger('main') terom@233: terom@233: def parse_options (argv) : terom@233: """ terom@233: Parse command-line arguments. terom@233: """ terom@233: terom@233: prog = argv[0] terom@233: terom@233: parser = optparse.OptionParser( terom@233: prog = prog, terom@233: usage = '%prog: [options]', terom@233: version = __version__, terom@233: terom@233: # module docstring terom@233: description = __doc__, terom@233: ) terom@233: terom@233: # logging terom@247: parser.add_option_group(pvl.args.parser(parser)) terom@233: terom@233: # input/output terom@332: parser.add_option('--input-charset', metavar='CHARSET', default='utf-8', terom@233: help="Encoding used for input files") terom@233: terom@258: parser.add_option('-o', '--output', metavar='FILE', default=None, terom@233: help="Write to output file; default stdout") terom@233: terom@233: parser.add_option('--output-charset', metavar='CHARSET', default='utf-8', terom@233: help="Encoding used for output files") terom@233: terom@233: # check stage terom@233: parser.add_option('--check-hosts', action='store_true', terom@233: help="Check that host/IPs are unique. Use --quiet to silence warnings, and test exit status") terom@233: terom@233: parser.add_option('--check-exempt', metavar='HOST', action='append', terom@233: help="Allow given names to have multiple records") terom@233: terom@233: # meta stage terom@233: parser.add_option('--meta-zone', action='store_true', terom@233: help="Generate host metadata zone; requires --input-line-date") terom@233: terom@233: parser.add_option('--meta-ignore', metavar='HOST', action='append', terom@233: help="Ignore given hostnames in metadata output") terom@233: terom@233: parser.add_option('--input-line-date', action='store_true', terom@233: help="Parse timestamp prefix from each input line (e.g. `hg blame | ...`)") terom@233: terom@233: # forward stage terom@233: parser.add_option('--forward-zone', action='store_true', terom@233: help="Generate forward zone") terom@233: terom@233: parser.add_option('--forward-txt', action='store_true', terom@233: help="Generate TXT records for forward zone") terom@233: terom@233: parser.add_option('--forward-mx', metavar='MX', terom@233: help="Generate MX records for forward zone") terom@233: terom@233: # reverse stage terom@233: parser.add_option('--reverse-domain', metavar='DOMAIN', terom@233: help="Domain to use for hosts in reverse zone") terom@233: terom@233: parser.add_option('--reverse-zone', metavar='NET', terom@233: help="Generate forward zone for given subnet (x.z.y | a:b:c:d)") terom@233: terom@252: # other terom@252: parser.add_option('--serial', metavar='YYMMDDXX', terom@252: help="Set serial for SOA record") terom@252: terom@316: parser.add_option('--include-path', metavar='PATH', terom@316: help="Rewrite includes to given absolute path") terom@316: terom@233: # defaults terom@233: parser.set_defaults( terom@233: # XXX: combine terom@233: check_exempt = [], terom@233: meta_ignore = [], terom@233: ) terom@233: terom@233: # parse terom@233: options, args = parser.parse_args(argv[1:]) terom@233: terom@247: # apply terom@247: pvl.args.apply(options, prog) terom@233: terom@233: return options, args terom@233: terom@293: def apply_zone_input (options, args) : terom@293: """ terom@293: Yield ZoneLine, ZoneRecord pairs from files. terom@293: """ terom@293: terom@293: for file in pvl.args.apply_files(args, 'r', options.input_charset) : terom@293: log.info("Reading zone: %s", file) terom@293: terom@293: for line, record in pvl.dns.zone.ZoneLine.load(file, terom@293: line_timestamp_prefix = options.input_line_date, terom@293: ) : terom@293: yield line, record terom@293: terom@293: # TODO: --check-types to limit this to A/AAAA/CNAME etc terom@233: def check_zone_hosts (zone, whitelist=None, whitelist_types=set(['TXT'])) : terom@233: """ terom@233: Parse host/IP pairs from the zone, and verify that they are unique. terom@233: terom@233: As an exception, names listed in the given whitelist may have multiple IPs. terom@233: """ terom@233: terom@233: by_name = {} terom@233: by_ip = {} terom@233: terom@233: fail = None terom@233: terom@233: last_name = None terom@233: terom@293: for l, r in zone : terom@293: if r : terom@293: name = r.name or last_name terom@233: terom@293: name = (r.origin, name) terom@233: terom@293: # name terom@293: if r.type not in whitelist_types : terom@293: if name not in by_name : terom@293: by_name[name] = r terom@233: terom@293: elif r.name in whitelist : terom@293: log.debug("Duplicate whitelist entry: %s", r) terom@233: terom@293: else : terom@293: # fail! terom@293: log.warn("%s: Duplicate name: %s <-> %s", r.line, r, by_name[name]) terom@293: fail = True terom@293: terom@293: # ip terom@293: if r.type == 'A' : terom@293: ip, = r.data terom@293: terom@293: if ip not in by_ip : terom@293: by_ip[ip] = r terom@293: terom@293: else : terom@293: # fail! terom@293: log.warn("%s: Duplicate IP: %s <-> %s", r.line, r, by_ip[ip]) terom@293: fail = True terom@293: terom@293: if fail : terom@293: log.error("Check failed, see warnings") terom@293: sys.exit(2) terom@293: terom@293: yield l, r terom@252: terom@252: def process_zone_serial (zone, serial) : terom@293: """ terom@293: Update the serial in the SOA record. terom@293: """ terom@293: terom@293: for line, rr in zone : terom@293: if rr and rr.type == 'SOA' : terom@252: # XXX: as SOA record.. terom@294: try : terom@294: soa = pvl.dns.zone.SOA.parse(line) terom@294: except TypeError as error : terom@294: log.exception("%s: unable to parse SOA: %s", rr.name, rr) terom@294: sys.exit(2) terom@293: terom@293: yield line, pvl.dns.zone.SOA( terom@293: soa.master, soa.contact, terom@293: serial, soa.refresh, soa.retry, soa.expire, soa.nxttl terom@293: ) terom@252: else : terom@293: yield line, rr terom@252: terom@233: def process_zone_forwards (zone, txt=False, mx=False) : terom@233: """ terom@233: Process zone data -> forward zone data. terom@233: """ terom@233: terom@293: for line, r in zone : terom@293: yield line, r terom@233: terom@293: if r and r.type == 'A' : terom@233: if txt : terom@233: # comment? terom@233: comment = r.line.comment terom@233: terom@233: if comment : terom@293: yield line, ZoneRecord.TXT(None, comment, ttl=r.ttl) terom@233: terom@233: terom@233: # XXX: RP, do we need it? terom@233: terom@233: if mx : terom@247: # XXX: is this even a good idea? terom@293: yield line, ZoneRecord.MX(None, 10, mx, ttl=r.ttl) terom@233: terom@233: def process_zone_meta (zone, ignore=None) : terom@233: """ terom@233: Process zone metadata -> output. terom@233: """ terom@233: terom@247: TIMESTAMP_FORMAT = '%Y/%m/%d' terom@233: terom@293: for line, r in zone : terom@233: if ignore and r.name in ignore : terom@233: # skip terom@233: log.debug("Ignore record: %s", r) terom@233: continue terom@233: terom@233: # for hosts.. terom@233: if r.type == 'A' : terom@233: # timestamp? terom@233: timestamp = r.line.timestamp terom@233: terom@233: if timestamp : terom@293: yield line, ZoneRecord.TXT(r.name, timestamp.strftime(TIMESTAMP_FORMAT), ttl=r.ttl) terom@233: terom@233: def process_zone_reverse (zone, origin, domain) : terom@233: """ terom@233: Process zone data -> reverse zone data. terom@233: """ terom@233: terom@293: for line, r in zone : terom@293: if r and r.type == 'A' : terom@233: ip, = r.data terom@233: ptr = reverse_ipv4(ip) terom@233: terom@293: elif r and r.type == 'AAAA' : terom@233: ip, = r.data terom@233: ptr = reverse_ipv6(ip) terom@233: terom@233: else : terom@293: yield line, r terom@233: continue terom@233: terom@233: # verify terom@233: if zone and ptr.endswith(origin) : terom@233: ptr = ptr[:-(len(origin) + 1)] terom@233: terom@233: else : terom@233: log.warning("Reverse does not match zone origin, skipping: (%s) -> %s <-> %s", ip, ptr, origin) terom@233: continue terom@233: terom@233: # domain to use terom@233: host_domain = r.origin or domain terom@233: host_fqdn = fqdn(name, host_domain) terom@233: terom@293: yield line, ZoneRecord.PTR(ptr, host_fqdn) terom@233: terom@316: def process_zone_includes (options, zone, path) : terom@316: """ terom@316: Rewrite include paths in zones. terom@316: """ terom@316: terom@316: for line, rr in zone : terom@316: if line.parts[0] == '$INCLUDE' : terom@316: _, include = line.parts terom@316: terom@316: yield pvl.dns.zone.ZoneLine( terom@316: line.file, terom@316: line.lineno, terom@316: line.line, terom@316: line.indent, terom@316: ['$INCLUDE', '"{path}"'.format(path=os.path.join(path, include))], terom@316: ), rr terom@316: else : terom@316: yield line, rr terom@316: terom@316: terom@293: def apply_zone_output (options, zone) : terom@293: """ terom@293: Write out the resulting zonefile. terom@293: """ terom@293: terom@293: file = pvl.args.apply_file(options.output, 'w', options.output_charset) terom@293: terom@293: for line, r in zone : terom@293: if r : terom@293: file.write(unicode(r)) terom@293: else : terom@316: file.write(unicode(line)) terom@247: file.write('\n') terom@233: terom@233: def main (argv) : terom@233: options, args = parse_options(argv) terom@258: terom@293: # input terom@293: zone = apply_zone_input(options, args) terom@233: terom@233: if options.check_hosts : terom@233: whitelist = set(options.check_exempt) terom@233: terom@293: log.info("Checking hosts: whitelist=%r", whitelist) terom@233: terom@293: zone = list(check_zone_hosts(zone, whitelist=whitelist)) terom@233: terom@252: if options.serial : terom@252: log.info("Set zone serial: %s", options.serial) terom@252: terom@252: zone = list(process_zone_serial(zone, serial=options.serial)) terom@252: terom@233: if options.forward_zone : terom@293: log.info("Generate forward zone...") terom@233: terom@233: zone = list(process_zone_forwards(zone, txt=options.forward_txt, mx=options.forward_mx)) terom@233: terom@293: if options.meta_zone : terom@293: log.info("Generate metadata zone...") terom@233: terom@233: if not options.input_line_date : terom@233: log.error("--meta-zone requires --input-line-date") terom@233: return 1 terom@233: terom@233: zone = list(process_zone_meta(zone, ignore=set(options.meta_ignore))) terom@233: terom@293: if options.reverse_zone : terom@233: if ':' in options.reverse_zone : terom@233: # IPv6 terom@233: origin = reverse_ipv6(options.reverse_zone) terom@233: terom@233: else : terom@233: # IPv4 terom@233: origin = reverse_ipv4(options.reverse_zone) terom@233: terom@233: domain = options.reverse_domain terom@233: terom@233: if not domain : terom@233: log.error("--reverse-zone requires --reverse-domain") terom@233: return 1 terom@233: terom@233: zone = list(process_zone_reverse(zone, origin=origin, domain=domain)) terom@293: terom@316: if options.include_path : terom@316: zone = list(process_zone_includes(options, zone, options.include_path)) terom@316: terom@293: # output terom@293: apply_zone_output(options, zone) terom@233: terom@233: return 0 terom@233: terom@233: if __name__ == '__main__': terom@233: import sys terom@233: sys.exit(main(sys.argv))