#!/usr/bin/python3
"""
Description:
Create/update mactab file from dhcpd.conf and arp

Usage: %(appname)s [-hvVs][-a arp][-d dhcpd.conf][-m mactab]
       -h, --help         this message
       -V, --version      print version and exit
       -v, --verbose      verbose mode (cumulative)
       -s, --simulate     don't modify mactab
       -a, --arp=file     [default: %(arp)s]
       -d, --dhcpd=file   [default: %(dhcpd)s]
       -m, --mactab=file  [default: %(mactab)s]

Providing empty args for arp or dhcp disables those sources.

Copyright:
(C)reated by %(author)s

License:
%(license)s

"""
#
# Changelog:
# 2006-05-19    hp  0.1     initial version
# 2010-03-29    hp  0.1.1   handle incomplete host sections properly, debug impr.
# 2018-05-04    hp  0.2     fetch ether data from arp
#
# vim:set et ts=8 sw=4:
#
# TODO:
#

__version__ = '0.2'
__author__ = 'Hans-Peter Jansen, LISA GmbH, Bingen, Germany <hpj@urpla.net>'
__license__ = 'GNU GPL 2 - see http://www.gnu.org/licenses/gpl.txt for details'


import os
import re
import sys
import copy
import time
import getopt
import pprint
import socket
import subprocess


class gpar:
    """ global parameter class """
    appdir, appname = os.path.split(sys.argv[0])
    if appdir == '.':
        appdir = os.getcwd()
    version = __version__
    author = __author__
    license = __license__
    verbose = 0
    simulate = False
    arp = '/sbin/arp'
    dhcpd = '/etc/dhcpd.conf'
    mactab = '/etc/mactab'


def out(arg, ch = sys.stdout):
    err(arg, ch)


def vout(arg, lvl = 1):
    if lvl <= gpar.verbose:
        err(arg)


def err(arg, ch = sys.stderr):
    if arg:
        ch.write(arg)
        if arg[-1] != '\n':
            ch.write('\n')
    else:
        ch.write('\n')
    ch.flush()


def exit(ret = 0, msg = None, usage = False):
    if msg:
        err('%s: %s' % (gpar.appname, msg))
    if usage:
        err(__doc__ % gpar.__dict__)
    sys.exit(ret)


def read_mactab(fd):
    macdict = {}    # macdict[ip] = (mac, host)
    lnr = 0
    for ln in fd:
        lnr += 1
        if not ln or ln.startswith('#'):
            continue
        vout('mactab[%s]: %s' % (lnr, ln), 2)
        m = re.search('(?P<mac>[0-9a-fA-F:]+)\s+(?P<host>\S+)\s+#\s+(?P<ip>\S+)', ln)
        if m:
            ip = m.group('ip')
            mac = m.group('mac')
            host = m.group('host')
            macdict[socket.inet_aton(ip)] = (mac, host)
            vout('mactab[%s]: %s: (%s, %s)' % (lnr, ip, mac, host))
        else:
            err('mactab[%s]: line malformed: %s' % (lnr, ln))
    return macdict


def parse_dhcpd_conf(fd):
    macdict = {}    # macdict[ip] = (mac, host)
    mac = None
    host = None
    lnr = 0
    for ln in fd:
        lnr += 1
        # remove comments
        cmt = ln.find('#')
        if cmt >= 0:
            ln = ln[:cmt]
        ln = ln.strip()
        if not ln:
            continue
        vout('dhcpd[%s]: %s' % (lnr, ln), 3)
        m = re.search('\s*host\s+(\S+)\s*{', ln)
        if m:
            mac = None
            host = None
            vout('host section: %s' % m.groups()[0], 2)
        if not host:
            m = re.search('fixed-address\s*(\S+)\s*;', ln)
            if m:
                host = m.groups()[0]
                vout('host address: %s' % host, 2)
        if not mac:
            m = re.search('hardware ethernet\s*([0-9a-fA-F:]+)\s*;', ln)
            if m:
                mac = m.groups()[0]
                vout('mac: %s' % mac, 2)
        if mac and host:
            try:
                ip = socket.gethostbyname(host)
            except OSError as e:
                err('cannot resolve ip address of host %s: %s' % (host, e))
            else:
                macdict[socket.inet_aton(ip)] = (mac, host)
                vout('dhcpd[%s]: %s: (%s, %s)' % (lnr, ip, mac, host))
            mac = None
            host = None
    return macdict


def fetch_arp_table(arp):
    macdict = {}    # macdict[ip] = (mac, host)
    try:
        arptab = subprocess.check_output([arp]).decode('utf-8')
    except subprocess.CalledProcessError as e:
        vout('failed to call %s: %s' % (arp, e))
    else:
        lnr = 0
        for ln in arptab.split('\n'):
            lnr += 1
            # ignore empty, incomplete and first line(s)
            if not ln or lnr == 1 or '(incomplete)' in ln:
                continue
            vout('arp[%s]: %s' % (lnr, ln), 2)
            m = re.search('(?P<host>\S+)\s+'
                          '(?P<type>\S+)\s+'
                          '(?P<mac>\S+)\s+'
                          '(?P<flags>\S+)\s+'
                          '(?P<iface>\S+)', ln)
            if m:
                mac = m.group('mac')
                host = m.group('host')
                if mac and host:
                    try:
                        ip = socket.gethostbyname(host)
                    except OSError as e:
                        err('cannot resolve ip address of host %s: %s' % (host, e))
                    else:
                        macdict[socket.inet_aton(ip)] = (mac, host)
                        vout('arp[%s]: %s: (%s, %s)' % (lnr, ip, mac, host))
            else:
                err('arp[%s]: line malformed: %s' % (lnr, ln))

    return macdict


def gen_mactab(macdict, mactab):
        try:
            with open(mactab, 'w') as fd:
                out("""\
# mactab
#
# mac to hostname mapping
#
# created: %s
# by:      %s v[%s]
#""" % (time.asctime(), gpar.appname, gpar.version), fd)
                for key, (mac, host) in sorted(macdict.items()):
                    out('%s %s # %s' % (mac, host, socket.inet_ntoa(key)), fd)
        except IOError as e:
            exit(3, 'cannot write: %s' % e)
        else:
            vout('%s successfully written' % mactab)


def show_modifications(olddict, macdict):
    for key, (mac, host) in sorted(macdict.items()):
        if key in olddict:
            if olddict[key] != (mac, host):
                oldmac, oldhost = olddict[key]
                vout('M: %s %s # %s' % (mac, host, socket.inet_ntoa(key)), 0)
                vout('O: %s %s # %s' % (oldmac, oldhost, socket.inet_ntoa(key)))
            else:
                vout('K: %s %s # %s' % (mac, host, socket.inet_ntoa(key)))

        else:
            vout('A: %s %s # %s' % (mac, host, socket.inet_ntoa(key)), 0)
    if gpar.verbose:
        vout('A: Added, M: Modified, O: Old, K: Kept')
    else:
        vout('A: Added, M: Modified', 0)


if __name__ == '__main__':
    try:
        optlist, args = getopt.getopt(sys.argv[1:], 'hVvsa:d:m:',
            ('help', 'verbose', 'simulate', 'arp', 'dhcpd', 'mactab'))
    except getopt.error as msg:
        exit(1, msg, True)

    for opt, par in optlist:
        if opt in ('-h', '--help'):
            exit(usage = True)
        elif opt in ('-V', '--version'):
            exit(msg = 'version: %s' % gpar.version)
        elif opt in ('-v', '--verbose'):
            gpar.verbose += 1
        elif opt in ('-s', '--simulate'):
            gpar.simulate = True
        elif opt in ('-a', '--arp'):
            if par:
                if not os.access(par, os.X_OK):
                    exit(1, 'no executable: %s' % par)
            gpar.arp = par

        elif opt in ('-d', '--dhcpd'):
            if par:
                if not os.access(par, os.R_OK):
                    exit(1, 'cannot access dhcpd.conf file: %s' % par)
            gpar.dhcpd = par
        elif opt in ('-m', '--mactab'):
            if not os.access(par, os.W_OK):
                exit(1, 'cannot write to mactab file: %s' % par)
            gpar.mactab = par

    # gather data
    # using binary representation of ip as key for ordering purposes
    macdict = {}    # macdict[ip] = (mac, host)
    olddict = {}    # a copy of old mactab

    try:
       with open(gpar.mactab, 'r') as fd:
           macdict = read_mactab(fd)
    except IOError as e:
        vout('cannot read old mactab file: %s' % e, 2)
    else:
        olddict = copy.deepcopy(macdict)

    if gpar.dhcpd:
        try:
            with open(gpar.dhcpd, 'r') as fd:
                dhcpdict = parse_dhcpd_conf(fd)
        except IOError as e:
            vout('cannot read dhcpd.conf file: %s' % e)
        else:
            macdict.update(dhcpdict)

    if gpar.arp:
        macdict.update(fetch_arp_table(gpar.arp))

    if macdict != olddict:
        if gpar.simulate or gpar.verbose > 1:
            show_modifications(olddict, macdict)
        if not gpar.simulate:
            gen_mactab(macdict, gpar.mactab)

    exit(0)

