#! /usr/bin/env python3
#
# freeipa-dns.py - python script to migrate and maintain DNS domains in FreeIPA
#
# Version 1.0, latest version, documentation and bugtracker available at:
#		https://gitlab.lindenaar.net/scripts/freeipa
#
# Copyright (c) 2018 Frederik Lindenaar
#
# This script is free software,you can redistribute and/or modify it under the
# terms of version 3 of the GNU General Public License as published by the Free
# Software Foundation, or (at your option) any later version of the license.
#
# This script is distributed in the hope that it will be useful but WITHOUT ANY
# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR
# A PARTICULAR PURPOSE.  See the GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License along with
# this program.  If not, visit <http://www.gnu.org/licenses/> to download it.

"""
migrate/synchronize and maintain DNS domain(s) with FreeIPA.

This script provides functionality not provided by FreeIPA to migrate and/or
synchronize / maintain DNS data in FreeIPA. Currently the following commands
are implemented:
    axfr            import/synchronize a DNS zone in FreeIPA using a zone-xfer
    copy            copy a DNS record in FreeIPA within or between zones
    move            move a DNS record in FreeIPA from one one to another
    serial          update (set) zone serial(s) in FreeIPA
    generate        generate number-range DNS records/attributes in FreeIPA
    reverse-ptr     create/update reverse DNS (PTR) entries in FreeIPA

for available commands run 'freeipa-dns.py -h and to get an overview of
the available options for each commmand run 'freeipa-dns.py <command> -h'
"""

import os, logging
from datetime import date
from argparse import ArgumentParser, FileType, \
              _StoreAction as StoreAction, _StoreConstAction as StoreConstAction

import dns.query
import dns.zone
from dns.rdatatype import SOA, HINFO
from dns.exception import DNSException

from ipalib import api
from ipalib.errors import PublicError, AuthenticationError, NotFound


VERSION="1.0"
PROG_NAME=os.path.splitext(os.path.basename(__file__))[0]
PROG_VERSION=PROG_NAME + ' ' + VERSION
LOG_FORMAT='%(levelname)s - %(message)s'
LOG_FORMAT_FILE='%(asctime)s - ' + LOG_FORMAT

logger = logging.getLogger(PROG_NAME)


##################[ Action to immediately set the log level ]##################
class SetLogLevel(StoreConstAction):
    """ArgumentParser action to set log level to provided const value"""
    def __call__(self, parser, namespace, values, option_string=None):
        logging.getLogger(PROG_NAME).setLevel(self.const)

####################[ Action to immediately log to a file ]####################
class SetLogFile(StoreAction):
    """ArgumentParser action to log to file (sets up FileHandler accordingly)"""
    def __call__(self, parser, namespace, values, option_string=None):
        super(SetLogFile, self).__call__(parser,namespace,values,option_string)
        formatter = logging.Formatter(LOG_FORMAT_FILE)
        handler = logging.FileHandler(values)
        handler.setFormatter(formatter)
        logger = logging.getLogger(PROG_NAME)
        logger.propagate = False
        logger.addHandler(handler)

def get_zone_opts(args):
    return {
        'dnsttl':           None if args.ttl is None else str(args.ttl),
        'dnsdefaultttl':    None if args.default_ttl is None else str(args.default_ttl),
        'idnsforwardpolicy':args.forward_policy,
        'idnsforwarders':   args.forwarder,
        'idnsallowquery':   ';'.join(args.allow_query)+';' if args.allow_query else None,
        'idnsallowtransfer':';'.join(args.allow_transfer)+';' if args.allow_transfer else None,
        'idnsallowsyncptr': args.create_reverse_ptr,
    }, {
        'skip_nameserver_check':args.no_ns_check,
        'skip_overlap_check':   args.no_ns_check,
    }, {
        'force':                args.no_ns_check,
    }

############################[ AXFR implementation ]############################
def process_zone(api, dnszone, args):
    """Process a single DNS Zone and synchronize with FreeIPA using its API"""
    domain = args.merge_zone if args.merge_zone else str(dnszone.origin)
    logger.info('processing DNS domain %s to %s', str(dnszone.origin), domain)
    soa_data = dnszone.get_rdataset(dnszone.origin, SOA)[0]
    zone_opts, add_opts, mod_opts = get_zone_opts(args)
    try:
        ipa_data = api.Command.dnszone_show(domain, all=True)['result']
        updates = { key: str(soa_value) for key, ipa_value in (
                (k, v[0]) for k,v in ipa_data.items() if k.startswith('idnssoa')
            ) for soa_value in ( getattr(soa_data, key[7:]), )
            if ( int(soa_value) > int(ipa_value) if key == 'idnssoaserial'
                                        else str(soa_value) != str(ipa_value) )
        }
        updates.update((key, value) for key,value in zone_opts.items()
                            if value is not None and not (key in ipa_data and
                                value in ipa_data[key] if isinstance(value, str)
                                else set(value).issubset(ipa_data.get(key))))
        if updates:
            logger.info('updating existing domain %s in FreeIPA', domain)
            logger.debug('updating domain %s with %s', domain, updates)
            api.Command.dnszone_mod(domain, **mod_opts, **updates)
        else:
            logger.debug('no updates for existing domain %s in FreeIPA', domain)
    except NotFound:
        logger.info('creating new domain %s in FreeIPA', domain)
        response = api.Command.dnszone_add(domain, **zone_opts, **add_opts, **{
            'idnssoa'+key: str(getattr(soa_data, key)) for key in [
                'mname','rname','serial','refresh','retry','expire','minimum']})
        print(response)

        api.Command.dnsrecord_del(domain, '@',
                                        nsrecord=response['result']['nsrecord'])

    recordname = lambda x: dns.rdatatype.to_text(x.rdtype).lower()+'record'
    optionchecks = tuple((key, datafield) for key, argopt, datafield in (
            ('force', 'no_ns_check', 'nsrecord'),
            ('a_extra_create_reverse', 'create_reverse_ptr', 'arecord'),
            ('aaaa_extra_create_reverse', 'create_reverse_ptr', 'aaaarecord')
    ) if getattr(args, argopt) )
    options = lambda data: { k: True for k, f in optionchecks if f in data }
    for rname, rdataset in dnszone.items():
        name = str(rname)
        dns_data = { recordname(rdatas[0]): tuple(map(str, rdatas))
                                for rdatas in rdataset.rdatasets
                                    if rdatas[0].rdtype not in (SOA, HINFO) }
        try:
            ipa_data = api.Command.dnsrecord_show(domain, name)['result']
            dns_data={ key: value for key, value in dns_data.items() if not (
                        key in ipa_data and set(value).issubset(ipa_data[key]))}
            if dns_data:
                logger.info('updating %s in domain %s in FreeIPA', name, domain)
        except NotFound:
            logger.info('adding %s to domain %s in FreeIPA', name, domain)
        finally:
            if dns_data:
                dns_data.update(options(dns_data))
                logger.debug(dns_data)
                api.Command.dnsrecord_add(domain, name, **dns_data)
            else:
                logger.debug('no updates for entry %s', name)

def axfr(api, args):
    for domain in args.dnszone:
        logger.info('performing zone-xfer for domain %s', domain)
        axfr_request = dns.query.xfr(args.dnsserver, domain,
                        source=args.source_address, relativize=args.relativize)
        dnszone = dns.zone.from_xfr(axfr_request, relativize=args.relativize)
        process_zone(api, dnszone, args)


############################[ COPY implementation ]############################
def copy(api, args, remove=False):
    """Copy a DNS Record in FreeIPA and optionally remove the source values"""
    src_record, src_zone = ('@', args.source_record) if args.source_zone else \
            (args.source_record, args.source_zone_name) if args.source_zone_name else \
            args.source_record.split('.', 1)
    dst_record, dst_zone = ('@', args.target_record) if args.target_zone else \
            (args.target_record, args.target_zone_name) if args.target_zone_name else \
            args.target_record.split('.', 1)

    logger.debug('%s %s in %s to %s in %s', 'Moving' if remove else 'Copying',
                                    src_record, src_zone, dst_record, dst_zone)

    src_data = { key:value for key,value in
        api.Command.dnsrecord_show(src_zone, src_record)['result'].items()
            if key.endswith('record') and
            (not args.limit_records or key[:-6].upper() in args.limit_records) }
    logger.debug("obtained source data from %s in domain %s,%s", src_record, src_zone, src_data)

    try:
        dst_data = api.Command.dnsrecord_show(dst_zone, dst_record)['result']
        if not args.merge:
            logger.error("Destination record %s already exists in zone %s", dst_record, dst_zone)
            exit(1)
        updates = { key: value for key, value in src_data.items()
            if not (key in dst_data and set(value).issubset(dst_data[key]))
        }
        if updates:
            logger.info('updating %s in domain %s in FreeIPA', dst_record, dst_zone)
    except NotFound:
        logger.info('creating %s in domain %s in FreeIPA', dst_record, dst_zone)
        updates = src_data

    if updates:
        logger.debug(updates)
        api.Command.dnsrecord_add(dst_zone, dst_record, **updates)['result']
    else:
        logger.debug('no updates for entry %s in domain %s', dst_record, dst_zone)

    if remove and src_data:
        if not args.limit_records:
            logger.info('removing %s from domain %s in FreeIPA', src_record, src_zone)
            api.Command.dnsrecord_delentry(src_zone, (src_record,))
        else:
            logger.info('removing attributes %s from %s in domain %s in FreeIPA',
                                    args.limit_records, src_record, src_zone)
            api.Command.dnsrecord_del(src_zone, src_record, **src_data)


############################[ MOVE implementation ]############################
def move(api, args):
    """Move a DNS Record from one DNS zone in FreeIPA to another or rename it"""
    copy(api, args, True)


###########################[ SERIAL implementation ]###########################
def serial(api, args, date=date.today()):
    """Set the SOA serial number of a DNS zone to specified or RFC1912 value"""
    serial = int(args.serial if args.serial else '%04d%02d%02d%02d'%(date.year,
                        0 if args.year_revision is not False else date.month,
                        0 if args.today_revision is False else date.day,
                        args.year_revision if args.year_revision else
                            args.month_revision if args.month_revision else
                            args.today_revision if args.today_revision else 0))
    for domain in args.dnszone:
        ipa_serial = int(
                api.Command.dnszone_show(domain)['result']['idnssoaserial'][0])
        if ipa_serial > serial and not (args.force or args.ignore_greater):
            logger.error('current serial %s of %s is greater than target (%s)',
                                                    ipa_serial, domain, serial)
            exit(1)
        elif serial > ipa_serial or (args.force and serial != ipa_serial):
            logger.info('updating serial of %s to %s', domain, serial)
            api.Command.dnszone_mod(domain, idnssoaserial=serial)


##########################[ GENERATE implementation ]##########################
def generate(api, args):
    """Generate DNS records / set DNS attributes based on a number range """
    records = { key: value for key in dir(args) if key.endswith('record')
                    for value in (getattr(args, key),) if value is not None }
    dynamic = [k for k, v in records.items() if any(map(lambda x: '%' in x, v))]
    autoincrement = { key: {'sep':':', 'maxvalue':65535, 'base':16, 'fmt':'%x'}
                        if key == 'aaaarecord' else {} for key in records.keys()
                            if getattr(args, 'auto_increment_'+key[:-6]) }
    def increment(value, sep='.', maxvalue=255, base=10, fmt='%d'):
        rest, _, value = value.rpartition(sep)
        value = 1 if value == '' or value == 'None' else int(value, base) + 1
        return rest + sep + fmt % value if value < maxvalue else \
                    increment(rest, sep, maxvalue, base, fmt) + sep + 1
    for number in range(args.start, args.end+1 if args.end else args.start+args.number):
        name = args.template % number if '%' in args.template else args.template
        if number > args.start:
            for key, params in autoincrement.items():
                records[key] = list(map(lambda x: x if '%' in x else \
                                        increment(x, **params), records[key]))
        dst_data = records.copy()
        for key in dynamic:
            dst_data[key] = list(map(lambda x: x%number if '%' in x else x, records[key]))
        try:
            ipa_data = api.Command.dnsrecord_show(args.dnszone, name)['result']
            updates = { key: value for key, value in dst_data.items()
                if not (key in ipa_data and set(value).issubset(ipa_data[key]))}
            if updates:
                logger.info('updating %s in domain %s in FreeIPA', name, args.dnszone)
                logger.debug(updates)
                api.Command.dnsrecord_mod(args.dnszone, name, **updates)
            else:
                logger.debug('no updates for entry %s in domain %s', name, args.dnszone)
        except NotFound:
            logger.info('creating %s in domain %s in FreeIPA', name, args.dnszone)
            logger.debug(updates)
            api.Command.dnsrecord_add(args.dnszone, name, **dst_data)


#########################[ REVERSEPTR implementation ]#########################
def reverseptr(api, args):
    rev_zones, ipv4_rev_zones, ipv6_rev_zones = {}, {}, {}
    logger.info('Fetching existing reverse PTR domains')
    for revzone in (zone for d in
            api.Command.dnszone_find('.arpa.',pkey_only=True)['result']
                for zone in map(str, d['idnsname']) if zone.endswith('.arpa.')):
        prefix, _, type = revzone[:-6].rpartition('.')
        if type == 'ip6':
            if not args.ipv6:
                continue
            reverse = lambda v: ''.join(reversed(v.replace('.','')))
            prefix = reverse(prefix)
            ipv6_rev_zones[prefix] = revzone
        else:
            if not args.ipv4:
                continue
            reverse = lambda v: '.'.join(reversed(v.split('.')))
            prefix = reverse(prefix) + '.'
            ipv4_rev_zones[prefix] = revzone
        if args.dnszone or args.all_zones:
            logger.debug('Loading reverse PTR domain %s for %s*',revzone,prefix)
            rev_zones[prefix] = { prefix+reverse(str(ptr)): host
                for d in api.Command.dnsrecord_find(revzone)['result']
                    for ptr in d['idnsname'] for host in d.get('ptrrecord', ())}
        else:
            rev_zones[prefix] = {}

    for revprefix in args.create_revprefix:
        try:
            if ':' in revprefix and args.ipv6:
                revtype = 'ipv6 '
                revprefix = revprefix.strip(': \t\r\n')
                revprefix = ''.join([ '%04x' % int(w,16)
                                            for w in revprefix.split(':',7) ])
                ipv6_rev_zones[revprefix] = revzone = \
                                '.'.join(reversed(revprefix)) + '.ip6.arpa.'
            elif '.' in revprefix and args.ipv4:
                revtype = 'ipv4 '
                revprefix = revprefix.strip('. \t\r\n')
                if revprefix.count('.') > 3 or any(map(lambda x: x<0 or x>255,
                                            map(int, revprefix.split('.')))):
                    raise ValueError('not a valid IPv4 value')
                revzone = '.'.join(reversed(revprefix.split('.'))) + '.in-addr.arpa.'
                revprefix += '.'
                ipv4_rev_zones[revprefix] = revzone
            else:
                revtype = ''
                raise ValueError('unsupported reverse zone prefix')
        except ValueError as e:
            logger.critical('Error: %sreverse zone prefix %s is not valid: %s',
                                                        revtype, revprefix, e)
            exit(1)
        else:
            if revprefix in rev_zones:
                logger.debug('%sreverse zone %s exists', revtype, revprefix)
            else:
                logger.info('Creating %sreverse zone %s for %s*',
                                                    revtype, revzone, revprefix)
                zone_opts, add_opts, _ = get_zone_opts(args)
                api.Command.dnszone_add(revzone, **zone_opts, **add_opts)
                rev_zones[revprefix] = {}

    revattrs = [ ('arecord', ipv4_rev_zones, sorted(ipv4_rev_zones), str,
        lambda t,v: '.'.join(reversed(v[len(t):].split('.')))) ] if args.ipv4 else []
    if args.ipv6:
        revattrs.append( ('aaaarecord', ipv6_rev_zones, sorted(ipv6_rev_zones),
                        lambda v: ''.join([ '0000' * (8-v.count(':')) if w == ''
                            else '%04x' % int(w, 16) for w in v.split(':') ]),
                        lambda t,v: '.'.join(reversed(v[len(t):]))) )
    excluded_zones = map(lambda x: x.strip(':.\t\r\n')+'.', args.exclude_zone)
    for zone in (z for z in ((n for d in
            api.Command.dnszone_find(pkey_only=True, idnszoneactive=True)['result']
                for n in map(str, d['idnsname'])) if args.all_zones
            else map(lambda x: x.strip(':.\t\r\n')+'.', args.dnszone))
                if not (z.endswith('in-addr.arpa.') or z.endswith('ip6.arpa.')
                                                    or z in excluded_zones)):
        logger.info('Processing DNS zone %s', zone)
        for record in ( api.Command.dnsrecord_show(zone, host)['result']
            for host in args.host) if args.host \
                                else api.Command.dnsrecord_find(zone)['result']:
            recordname = str(record['idnsname'][0])
            recordname = zone if recordname=='@' else '%s.%s'%(recordname,zone)
            for revattr,revzones,revzonelist,revconvert,revformat in revattrs:
                for ipaddr in record.get(revattr, ()):
                    revaddr = revconvert(ipaddr)
                    try:
                        *oldrevs, revtarget = [ rz for rz in revzonelist
                                                    if revaddr.startswith(rz) ]
                    except ValueError:
                        logger.debug('Skipping %s (%s): no reverse DNS zone',
                                                            ipaddr, recordname)
                    else:
                        reventry = revformat(revtarget, revaddr)
                        revzone = revzones[revtarget]
                        currrev = rev_zones[revtarget].get(revaddr)
                        if currrev == recordname:
                            logger.debug('no update for %s (%s) in %s',
                                                reventry, recordname, revzone)
                        elif currrev and not args.override:
                            logger.warn('not updating %s (%s) in %s pointing to'
                                ' %s', reventry, recordname, revzone, currrev)
                        else:
                            action = 'modifying' if currrev else 'adding'
                            logger.info('%s %s (%s) to %s', action, reventry,
                                                            recordname, revzone)
                            getattr(api.Command, 'dnsrecord_%s' % action[:3])(
                                        revzone, reventry, ptrrecord=recordname)
                            rev_zones[revtarget][revaddr] = recordname
                        for revzone, reventry, oldentry in ( (revzones[r],
                                    revformat(r, revaddr), o) for r in oldrevs
                                    for o in (rev_zones[r].get(revaddr),) if o):
                            logger.info('removing %s (%s) from %s', reventry,
                                                            oldentry, revzone)
                            api.Command.dnsrecord_delentry(revzone, reventry)


##########################[ Command-line processing ]##########################
def parse_args():
    """Parse command line and get parameters from environment if not set"""

    record_types = (
        ('A',       '4', 'IPv4 address'),
        ('AAAA',    '6', 'IPv6 address'),
        ('CNAME',   'c', 'canonical name (alias)'),
        ('MX',      'm', 'Mail Exchange (priority + server)'),
        ('NS',      'N', 'Nameserver name'),
        ('PTR',     'p', 'Reverse address Pointer'),
        ('SRV',     'V', 'Service record (priority+port+server)'),
        ('TXT',     't', 'Text record'),
        ('SSHFP',   'H', 'SSH Fingerprint (priority+type+fingerprint)'),
    )
    parser = ArgumentParser(
        description='Migrate or synchronize a DNS zone in FreeIPA with DNS',
    )
    parser.add_argument('-V', '--version',action="version",version=PROG_VERSION)

    pgroup = parser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-q', '--quiet',   action=SetLogLevel, const=logging.CRITICAL,
                        default=logging.CRITICAL, help='quiet (only fatal errors)')
    pgroup.add_argument('-v', '--verbose', action=SetLogLevel, const=logging.INFO,
                        help='more verbose output')
    pgroup.add_argument('-d', '--debug',   action=SetLogLevel, const=logging.DEBUG,
                        help='debug output')

    parser.add_argument('-l', '--logfile', action=SetLogFile,
                        help='send logging output to logfile')

    zoneoptionsparser = ArgumentParser(add_help=False)
    zoneoptionsparser.add_argument('-n', '--no-ns-check', action='store_true',
                        help='force zone/record creation in case NS not in DNS')
    zoneoptionsparser.add_argument('-p', '--create-reverse-ptr', action='store_true',
                        help='Enable updating reverse records for created zone(s)')
    zoneoptionsparser.add_argument('-f','--forward-policy',choices=('first','only','none'),
                        default='first', help='Set forward policy for created zone(s)')
    zoneoptionsparser.add_argument('-F', '--forwarder', nargs="+",
                        help='Set IP addresses to forward queries for created zone(s)')
    zoneoptionsparser.add_argument('-Q', '--allow-query', nargs="+", default=(),
                        help='Set IP addresses/ranges that can query created zone(s)')
    zoneoptionsparser.add_argument('-T', '--allow-transfer', nargs="+", default=(),
                        help='Set IP addresses/ranges that can transfer created zone(s)')
    zoneoptionsparser.add_argument('-t', '--ttl', type=int,
                        help='Set TTL for SOA record of created zone(s)')
    zoneoptionsparser.add_argument('-D', '--default-ttl', type=int,
                        help='Set default TTL for records in created zone(s)')

    subparser = parser.add_subparsers(title='Available commands', dest='command')
    subparser.required=True
    cmdparser = subparser.add_parser('axfr',    parents=[zoneoptionsparser],
                help='import or synchronize an DNS zone in FreeIPA with the '
                        'result of a zone-xfer',
                description='Migrate or synchronize a DNS zone in FreeIPA with '
                    'an external DNS server. Since it synchronizes data it is '
                    'safe to run multiple times for a domain.',
                epilog='Please note that this uses a domain-xfer to fetch DNS '
                        'zone(s) so the DNS server must allow a Zone Transfer '
                        'for the domain from the host running the script for '
                        'this command to work')
    cmdparser.set_defaults(func=axfr,   cmdparser=cmdparser)
    cmdparser.add_argument('-s', '--source-address',
                        help='perform the zone-xfr from specified address')
    pgroup = cmdparser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-r', '--relativize', action='store_true', default=True,
                        help='store DNS records relative to zone origin')
    pgroup.add_argument('-a','--absolute',action='store_false',dest='relativize',
                        help='store DNS records with absolute DNS domain')

    cmdparser.add_argument('-m', '--merge-zone',
                            help='merge DNSZONE(s) into MERGE_ZONE')
    cmdparser.add_argument('dnsserver', help='DNS Server to request the zone from')
    cmdparser.add_argument('dnszone',   nargs='+',  help='DNS Zone to synchronize')

    commonparser = ArgumentParser(add_help=False)
    commonparser.add_argument('-m', '--merge', action='store_true',
                            help='merge SRC_RECORD into DST_RECORD (default is '
                                    'to fail when DST_RECORD exists)')
    commonparser.add_argument('-l', '--limit-records', nargs='+',
                                choices=list(map(lambda x:x[0], record_types)),
                            help='only move specified record types (default is to move all)')
    pgroup = commonparser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-z', '--source-zone-name',
                            help='DNS zone to move SRC_RECORD from (will use SRC_RECORD if not provided)')
    pgroup.add_argument('-Z', '--source-zone', action='store_true',
                            help='move attributes from DNS zone SRC_RECORD itself')
    pgroup = commonparser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-t', '--target-zone-name',
                            help='new name for SRC_RECORD in DST_RECORD')
    pgroup.add_argument('-T', '--target-zone', action='store_true',
                            help='move attributes from to DNS zone SRC_RECORD itself')
    commonparser.add_argument('source_record', help='source record(s) to move')
    commonparser.add_argument('target_record', help='DNS Zone to move records to')

    cmdparser = subparser.add_parser('copy',parents=[commonparser],
                    help='Copy a DNS record in FreeIPA within or between zones')
    cmdparser.set_defaults(func=copy,       cmdparser=cmdparser)

    cmdparser = subparser.add_parser('move',parents=[commonparser],
                    help='Move a DNS record in FreeIPA from one one to another')
    cmdparser.set_defaults(func=move,       cmdparser=cmdparser)

    cmdparser = subparser.add_parser('serial',
                                help='update (set) zone serial(s) in FreeIPA')
    cmdparser.set_defaults(func=serial,   cmdparser=cmdparser)
    pgroup = cmdparser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-f', '--force', action='store_true',
                        help='force setting the SOA serial, even when smaller '
                                '(default is to only update to larger value)')
    pgroup.add_argument('-i', '--ignore-greater', action='store_true',
                        help='silently ignore exisging larger SOA serials '
                           '(default is to abort if existing serial is larger)')
    pgroup = cmdparser.add_mutually_exclusive_group(required=True)
    generate_RFC1912 = 'generate RFC1912-format serial based on %s'
    pgroup.add_argument('-y', '--current-year', type=int,
                    dest='year_revision', nargs='?', default=False,
                    help=generate_RFC1912 % 'current year (YYYY######)')
    pgroup.add_argument('-m', '--current-month', type=int,
                    dest='month_revision', nargs='?', default=False,
                    help=generate_RFC1912 % 'current year & month (YYYYMM####)')
    pgroup.add_argument('-t', '--today', type=int,
                    dest='today_revision', nargs='?', default=False,
                    help=generate_RFC1912 % 'current date (YYYYMMDD##)')
    pgroup.add_argument('-s', '--serial', type=int,
                    help='serial value to use (must be a 32-bit integer)')
    cmdparser.add_argument('dnszone',   nargs='+',
                    help='DNS Zone to update serial for')

    cmdparser = subparser.add_parser('generate',
         help='generate number-range DNS records/attributes in FreeIPA',
         epilog='(*) %s/%d will be replaced with the sequence numnber, specify'
                ' #leading zeros like with printf, i.e. %02d = 2 leading zeros')
    cmdparser.set_defaults(func=generate,   cmdparser=cmdparser)
    cmdparser.add_argument('-s', '--start', type=int, default=1,
                            help='start (first) value of the numner range to generate, defaults to 1')
    pgroup = cmdparser.add_mutually_exclusive_group(required=True)
    pgroup.add_argument('-e', '--end', type=int,
                            help='end (last) value of the numner range to generate')
    pgroup.add_argument('-n', '--number', type=int,
                            help='number of entries to generate')
    for record, option, description in record_types:
        cmdparser.add_argument('-'+option, '--'+record.lower()+'record',
                        nargs='+', help='set %s for record (*)' % description)
        if option in ('4', '6'):
            cmdparser.add_argument('--auto-increment-'+record.lower(),
                    action='store_true', help='Automatically increment %s values without a pattern' % description)
    cmdparser.add_argument('dnszone',   help='DNS Zone to generate records in')
    cmdparser.add_argument('template',  help='template for the name of the generated records (*)')

    cmdparser = subparser.add_parser('reverse-ptr', parents=[zoneoptionsparser],
                help='create/update reverse DNS (PTR) entries in FreeIPA',
                description='Generate IPv4/IPv6 reverse PTR zones and records.')
    cmdparser.set_defaults(func=reverseptr,   cmdparser=cmdparser)
    pgroup = cmdparser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-4', '--ipv4', action='store_false', dest='ipv6', default=True,
                            help='process only ipv4 addresses')
    pgroup.add_argument('-6', '--ipv6', action='store_false', dest='ipv4', default=True,
                            help='process only ipv6 addresses')
    cmdparser.add_argument('-o', '--override', action='store_true',
                            help='overwrite existing reverse PTR records (default is to only add new mappings)')
    cmdparser.add_argument('-c', '--create_revprefix', nargs='+', default=(),
                            help='create reverse zone for IPv4/IPv6 prefix '
                '(must contain either . or :, e.g. 10. 192.168 2001:0db8:85a3)')
    pgroup = cmdparser.add_mutually_exclusive_group(required=False)
    pgroup.add_argument('-a', '--all-zones', action='store_true',
                            help='process all enabled zones in FreeIPA')
    pgroup.add_argument('-z', '--dnszone', nargs='+', default=(),
                        help='DNS zone(s) to generate reverse pointer records for')
    cmdparser.add_argument('-H', '--host', nargs='+',
                            help='DNS host(s) to generate reverse pointer records for within each DNSZONE')
    cmdparser.add_argument('-x', '--exclude-zone', nargs='+', default=(),
                            help='exclude specified zones')

    return parser.parse_args()


###############################################################################

if __name__ == '__main__':
    logging.basicConfig(format=LOG_FORMAT)
    args = parse_args()

    try:
        logger.debug('connecting to FreeIPA')
        if api.isdone('finalize') is False:
            api.bootstrap_with_global_options(context='api')
            api.finalize()
        api.Backend.rpcclient.connect()
        args.func(api, args)
        exit(0)
    except DNSException as e:
        logger.critical("Domain cannot be downloaded: %s", e)
    except AuthenticationError:
        logger.critical("Unable to authenticate to FreeIPA, make sure you have a valid Kerberos ticket!")
    except PublicError as e:
        logger.critical("error while communicating with FreeIPA: %s", e)
    exit(1)