#!/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)