#!/usr/bin/python3
# -
# Copyright 2019 Dominik George <d.george@tarent.de>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to
# deal in the Software without restriction, including without limitation the
# rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
# sell copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in
# all copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.  IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
# FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
# IN THE SOFTWARE.

import argparse
import binascii
import random
import re
from urllib.parse import urlparse

try:
    import ldap3
except ImportError:
    # LDAP support is optional
    pass

# Initial seed used for first iteration of MAC generation
PEARSON_SEED = 1990


def normalise_hostname(hostname: str) -> str:
    """Convert a hostname string to lower case and verify it fulfills the
    LDH character set (lower-case, digits and hyphen).
    """

    hostname_norm = hostname.lower()

    if re.match(r"^[a-z0-9-]{1,63}$", hostname_norm):
        return hostname_norm
    else:
        raise ValueError("Hostnames must be 1 to 63 LDH characters.")


def pearson8(message: str, seed: int = PEARSON_SEED) -> int:
    """Calculate the 8-bit Pearson hash of a message."""

    table = list(range(0, 256))
    random.Random(seed).shuffle(table)

    pearson = len(message) % 256

    for char in message:
        pearson = table[pearson ^ ord(char)]

    return pearson


def mac_split(hex_string: str) -> str:
    """Separate the octets of a MAC address hex string by a colon."""

    return ':'.join(a+b for a, b in zip(hex_string[::2], hex_string[1::2]))


def mac_from_hostname(hostname: str, pearson_seed: int = PEARSON_SEED) -> str:
    """Generate a MAC address fulfilling the following criteria:

     * Be in one of the private use areas
     * Be reproducible (same hostname -> same MAC address)
     * Be sufficiently collision-free

    The generated MAC address takes the following format:

     ab:cc:cc:cc:cc:dd

     ab -> Length of the hostname, seperated in modulo 16 (a)
           and divided by 16 (b mapped to "2", "4", "6", "a") parts
     cc -> CRC32 checksum of the hostname
     dd -> 8-bit Pearson hash of the hostname

    The way ab is calculated ensures that the result is a byte in
    one of the private-use MAC areas; dd and ab together enlarge the
    uniqueness that a CRC32 alone does not guarantee.
    """

    hostname_norm = normalise_hostname(hostname)

    hostname_len = "%01x" % (len(hostname_norm) % 16) + \
        ["2", "4", "6", "a"][int(len(hostname_norm) / 16)]
    hostname_crc = "%08x" % binascii.crc32(hostname_norm.encode('ascii'))
    hostname_pearson = "%02x" % pearson8(hostname_norm, pearson_seed)

    hex_string = hostname_len + hostname_crc + hostname_pearson
    return mac_split(hex_string)


def mac_random(**kwargs):
    """Return a pseudo-random MAC address within one of the private ranges."""

    return ':'.join(['5%01x' % random.choice([2, 4, 6, 10])] + ['%02x' % random.randint(0, 255) for _ in range(0, 5)])


def ldap_find_mac(mac: str, hostname: str, ldap_uri: str, schema: str = 'isc'):
    """Check whether a MAC address is registered in an LDAP directory.

    This function returns True if the provided MAC address is
    already known in LDAP, but for a different hostname.

    It returns False if the MAC address is either not registered, or
    registered for the same hostname.
    """

    parsed_uri = urlparse(ldap_uri)

    server = ldap3.Server(parsed_uri.hostname, port=parsed_uri.port or 389,
                          use_ssl=parsed_uri.scheme == 'ldaps')
    conn = ldap3.Connection(server, auto_bind=True)

    if schema == 'isc':
        query = '(&(dhcpHWAddress=ethernet %s)(!(cn=%s)))' % (mac, hostname)

    return conn.search(parsed_uri.path.strip('/'), query)


if __name__ == '__main__':
    parser = argparse.ArgumentParser(
        description='Generate random or reproducible MAC address')

    parser.add_argument(
        '-r', '--random', action='store_true', help='Generate random MAC address instead of reproducible')
    parser.add_argument('-L', '--ldap-uri',
                        help='Query an LDAP directory for used MAC addresses')
    parser.add_argument('-S', '--ldap-schema', choices=['isc'], default='isc',
                        help='LDAP schema to use with -L')
    parser.add_argument('hostname',
                        help='Hostname (without domain) for the target host')

    args = parser.parse_args()

    if args.ldap_uri and 'ldap3' not in globals():
        print('LDAP checking enabled, but python3-ldap is not available.')
        exit(1)

    if args.random:
        gen_func = mac_random
    else:
        gen_func = mac_from_hostname

    mac_taken = True
    pearson_seed = PEARSON_SEED

    while mac_taken:
        mac = gen_func(hostname=args.hostname, pearson_seed=pearson_seed)

        if args.ldap_uri:
            mac_taken = ldap_find_mac(
                mac, args.hostname, args.ldap_uri, args.ldap_schema)
        else:
            # Assume MAC is free if verification is not enabled
            mac_taken = False

        # If MAC was already taken, try again with a new pearson seed
        # This has nothing to say for the random generator
        pearson_seed += 1

    print(mac)