Source code for bloxone.dhcputils

#!/usr/local/bin/python3
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
'''
------------------------------------------------------------------------

 Description:
    Simple utility functions for data type validation, domain handling,
    and data normalisationa specifically with the aim of supporting
    queries to TIDE and Dossier.

 Requirements:
   Python3 with re, ipaddress, requests 

 Author: Chris Marrison

 Date Last Updated: 20210721

 Todo:

 Copyright (c) 2021 Chris Marrison / Infoblox

 Redistribution and use in source and binary forms,
 with or without modification, are permitted provided
 that the following conditions are met:

 1. Redistributions of source code must retain the above copyright
 notice, this list of conditions and the following disclaimer.

 2. Redistributions in binary form must reproduce the above copyright
 notice, this list of conditions and the following disclaimer in the
 documentation and/or other materials provided with the distribution.

 THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
 "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
 LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
 FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
 COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
 INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
 BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
 LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
 CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
 LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
 ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
 POSSIBILITY OF SUCH DAMAGE.

------------------------------------------------------------------------
'''
__version__ = '0.1.3'
__author__ = 'Chris Marrison'
__author_email__ = 'chris@infoblox.com'

import logging
import ipaddress
import os
import yaml
import binascii
import bloxone

# ** Global Vars **
# DHCP Encoding Utils

[docs]class dhcp_encode(): ''' Class to assist with Hex Encoding of DHCP Options and sub_options ''' def __init__(self) -> None: self.opt_types = [ 'string', 'ipv4_address', 'ipv6_address', 'boolean', 'int8', 'uint8', 'int16', 'uint16', 'int32', 'uint32', 'fqdn', 'binary', 'empty' ] self.fqdn_re, self.url_re = bloxone.utils.buildregex() return
[docs] def validate_ip(self, ip): ''' Validate input data is a valid IP address (Supports both IPv4 and IPv6) Parameters: ip (str): ip address as a string Returns: bool: Return True for valid and False otherwise ''' try: ipaddress.ip_address(ip) result = True except ValueError: result = False return result
# IP encondings
[docs] def ip_to_hex(self, ip): ''' Encode an IPv4 or IPv6 address to hex Parameters: ip (str): IPv4 or IPv6 address as a string Returns: hex encoding as string ''' if self.validate_ip(ip): ip = ipaddress.ip_address(ip) hex_value = '{:x}'.format(ip) else: hex_value = None return hex_value
# Methods for IPv4 and IPv6
[docs] def ipv4_address_to_hex(self, ipv4): ''' Encode an IPv4 address to hex Parameters: ipv4 (str): IPv4 address as a string Returns: hex encoding as string ''' return self.ip_to_hex(ipv4)
[docs] def ipv6_address_to_hex(self, ipv6): ''' Encode an IPv6 address to hex Parameters: ipv6 (str): IPv4 or IPv6 address as a string Returns: hex encoding as string ''' return self.ip_to_hex(ipv6)
# String/text encoding
[docs] def string_to_hex(self, string): ''' Encode a text string to hex Parameters: string (str): text string Returns: hex encoding as string ''' s = str(string).encode('utf-8') return s.hex()
# Boolean encoding
[docs] def boolean_to_hex(self, flag): ''' Encode boolean value as single hex byte Parameters: flag (bool/str): True or False as bool or text Returns: hex encoding as string ''' # Handle Bool or str if not isinstance(flag, bool): if isinstance(flag, str): if flag.casefold() == 'true': flag = True else: flag = False else: flag = False # Set hex value if flag: hex = '01' else: hex = '00' return hex
# integer encodings
[docs] def int_to_hex(self, i, size = 8): ''' Encode integer of specified size as signed int in hex Parameters: i (int): integer value to encode size (int): size in bits [8, 16, 32] Returns: hex encoding as string ''' hex_value = '' i = int(i) i_sizes = [ 8, 16, 32 ] if size in i_sizes: max_bits = size - 1 no_bytes = size // 4 fmt = f'{{:0{no_bytes}x}}' if i > 0 and i < (2**max_bits): hex_value = fmt.format(int(i)) elif abs(i) <= (2**max_bits): hex_value = fmt.format(int(abs(i) + (2**max_bits))) else: raise TypeError(f'{i} is out of range for int{size} type') return hex_value
[docs] def uint_to_hex(self, i, size = 8): ''' Encode integer of specified size as unsigned int in hex Uses 2's compliment if supplied with negative number Parameters: i (int): integer value to encode size (int): size in bits [8, 16, 32] Returns: hex encoding as string ''' i = int(i) i_sizes = [ 8, 16, 32 ] if size in i_sizes: max_size = 2**size no_bytes = size // 4 fmt = f'{{:0{no_bytes}x}}' if i < max_size: hex = fmt.format(i + (2**size)) else: raise TypeError(f'{i} is out of range for uint{size} type') return hex
# Methods for intX and uintX def int8_to_hex(self, value): return self.int_to_hex(value, size=8) def uint8_to_hex(self, value): return self.uint_to_hex(value, size=8) def int16_to_hex(self, value): return self.int_to_hex(value, size=16) def uint16_to_hex(self, value): return self.uint_to_hex(value, size=16) def int32_to_hex(self, value): return self.int_to_hex(value, size=32) def uint32_to_hex(self, value): return self.uint_to_hex(value, size=32) # FDQN Encoding
[docs] def fqdn_to_hex(self, fqdn): ''' Encode an fdqn in RFC 1035 Section 3.1 formatted hex Parameters: fqdn (str): hostname to encode Returns: hex encoding as string ''' hex = '' hex_label = '' # Validate FQDN if bloxone.utils.validate_fqdn(fqdn, self.fqdn_re): if fqdn.endswith("."): # strip exactly one dot from the right, if present fqdn = fqdn[:-1] # Encode labels for label in fqdn.split("."): hex_label = self.string_to_hex(label) hex_len = self.hex_length(hex_label) hex += hex_len + hex_label # Terminate with null byte hex += '00' else: logging.error(f'{fqdn} is not a valid FQDN') return hex
# Binary Encoding
[docs] def binary_to_hex(self, data): ''' Format hex string of binary/hex encoded data Parameters: data (str): data to format Returns: hex encoding as string ''' hex_value = '' base = 16 # Check for binary if data[:2] == '0b': base = 2 # Force hex encoding without 0x using base hex_value = '{:02x}'.format(int(data, base)) return hex_value
# Empty Encoding
[docs] def empty_to_hex(self, data): ''' ''' if data: data = '' return data
# Code and Length encoding
[docs] def optcode_to_hex(self, optcode): ''' ''' hex_opt = '{:02x}'.format(int(optcode)) return hex_opt
[docs] def hex_length(self, hex_string): ''' ''' hex_len = '{:02x}'.format(int(len(hex_string) / 2)) return hex_len
# Encoding Methods
[docs] def encode_data(self, sub_opt, padding=False, pad_bytes=1): ''' ''' hex_data = '' if sub_opt['type'] in self.opt_types: type_to_hex = eval('self.' + sub_opt['type'] + '_to_hex') else: logging.error(f'Unsupported Option Type {sub_opt["type"]}') logging.info('Unsupported option type, ' + 'attempting to process as string') type_to_hex = eval('self.string_to_hex') # Check for array attribute if 'array' in sub_opt.keys(): array = sub_opt['array'] else: logging.debug(f'No array attribute for option: {sub_opt["code"]}') array = False # Encode data if array and (len(sub_opt['data'].split(',')) > 1): for item in sub_opt['data'].split(','): hex_data += type_to_hex(item) else: hex_data = type_to_hex(sub_opt['data']) if padding: hex_data += pad_bytes * '00' return hex_data
[docs] def encode_sub_option(self, sub_opt, data_only=False, padding=False, pad_bytes=1): ''' ''' # Local variables hex_value = '' hex_opt = '' hex_len = '' hex_data = '' if 'data-only' in sub_opt.keys() or data_only: if sub_opt['data_only']: hex_data = self.encode_data(sub_opt) hex_value = hex_data else: if int(sub_opt['code']) in range(0, 256): hex_opt = self.optcode_to_hex(sub_opt['code']) hex_data = self.encode_data(sub_opt) hex_len = self.hex_length(hex_data) hex_value = hex_opt + hex_len + hex_data else: # Log error (or potentially raise exception or something) logging.error(f'Option Code: {sub_opt["code"]} out of range.') hex_value = '' return hex_value
[docs] def encode_dhcp_option(self, sub_opt_defs=[], padding=False, pad_bytes=1, encapsulate=False, id=None ): ''' ''' hex_value = '' # Encode sub_options for opt in sub_opt_defs: hex_value += self.encode_sub_option(opt) if encapsulate: total_len = self.hex_length(hex) main_opt = self.optcode_to_hex(id) hex_value = main_opt + total_len + hex_value return hex_value
[docs] def tests(self): ''' ''' test_data = [ { 'code': '1', 'type': 'string', 'data': 'AABBDDCCEEDD-aabbccddeeff' }, { 'code': '2', 'type': 'ipv4_address', 'data': '10.10.10.10' }, { 'code': '3', 'type': 'ipv4_address', 'data': '10.10.10.10,11.11.11.11', 'array': True }, { 'code': '4', 'type': 'boolean', 'data': True }, { 'code': '5', 'type': 'int8', 'data': '22' }, { 'code': '5', 'type': 'int8', 'data': '-22' }, { 'code': '6', 'type': 'uint8', 'data': '22' }, { 'code': '7', 'type': 'int16', 'data': '33'}, { 'code': '8', 'type': 'int16', 'data': '33'}, { 'code': '9', 'type': 'uint16', 'data': '33'}, { 'code': '10', 'type': 'int32', 'data': '44'}, { 'code': '11', 'type': 'uint32', 'data': '-44'}, { 'code': '12', 'type': 'uint32', 'data': '44'}, { 'code': '13', 'type': 'fqdn', 'data': 'www.infoblox.com' }, { 'code': '14', 'type': 'binary', 'data': 'DEADBEEF' }, { 'code': '15', 'type': 'empty', 'data': ''}, { 'code': '16', 'type': 'ipv6_address', 'data': '2001:DB8::1' }, { 'code': '17', 'type': 'ipv6_address', 'data': '2001:DB8::1,2001:DB8::2', 'array': True } ] print(f'Encoding types supported: {self.opt_types}') print() print('Non-array tests:') for data_test in test_data: result = self.encode_data(data_test) hex_len = self.hex_length(result) print(f'Type: {data_test["type"]}: {data_test["data"]}, ' + f'Encoded: {result}, Length(hex): {hex_len}') print() # Padding Test test_data = { 'code': '99', 'type': 'string', 'data': 'AABBCCDD' } result = self.encode_data(test_data, padding=True) print(f'Padding test (1 byte), type string: {test_data["data"]} {result}') # Full encode test test_data = [ { 'code': '1', 'type': 'string', 'data': 'AABBDDCCEEDD-aabbccddeeff' }, { 'code': '2', 'type': 'ipv4_address', 'data': '10.10.10.10' }, { 'code': '3', 'type': 'ipv4_address', 'data': '10.10.10.10,11.11.11.11', 'array': True }, { 'code': '4', 'type': 'boolean', 'data': True }, { 'code': '5', 'type': 'int8', 'data': '22' } ] result = self.encode_dhcp_option(test_data) print(f'Full encoding of sample: {result}') return
# *** Class to handle Vendor Option Definitions Dictionary in YAML ***
[docs]class DHCP_OPTION_DEFS(): ''' Class to load and handle DHCP Option Defs ''' def __init__(self, cfg='vendor_dict.yaml'): ''' Initialise Class Using YAML config ''' self.config = {} # Check for yaml file and raise exception if not found if os.path.isfile(cfg): # Read yaml configuration file try: self.config = yaml.safe_load(open(cfg, 'r')) except yaml.YAMLError as err: logging.error(err) raise else: logging.error(f'No such file {cfg}') raise FileNotFoundError(f'YAML config file "{cfg}" not found.') return
[docs] def version(self): ''' Returns config file version ''' return self.config['version']
[docs] def keys(self): ''' Returns top level keys ''' return self.config.keys()
[docs] def vendors(self): ''' Returns list of vendors ''' return self.config['vendors'].keys()
[docs] def vendor_keys(self, vendor): ''' Returns vendor top level keys ''' if self.included(vendor): response = self.config['vendors'][vendor].keys() else: response = None return response
[docs] def count(self): ''' Returns count of defined vendors ''' return len(self.config['vendors'])
[docs] def included(self, vendor): ''' Check whether this vendor is configured Returns bool ''' status = False if vendor in self.vendors(): status = True else: status = False return status
[docs] def vendor_description(self, vendor): ''' Returns description of vendor ''' desc = None if self.included(vendor): desc = self.config['vendors'][vendor]['description'] else: desc = None return desc
[docs] def option_def(self, vendor): ''' Returns option definition as dict ''' opt_def = {} if self.included(vendor): if 'option-def' in self.vendor_keys(vendor): opt_def = self.config['vendors'][vendor]['option-def'] else: logging.error(f'No option definition for vendor {vendor}') else: logging.error(f'Vendor: {vendor} not defined') return opt_def
[docs] def parent_opt_def(self, vendor): ''' Returns parent-option definition as dict ''' opt_def = {} parent_def = {} if self.included(vendor): opt_def = self.option_def(vendor) if 'parent-option' in opt_def.keys(): parent_def = opt_def['parent-option'] else: logging.error(f'No parent-option for vendor {vendor}') else: logging.error(f'Vendor: {vendor} not defined') return parent_def
[docs] def sub_options(self, vendor): ''' Returns list of sub-option definitions ''' opt_def = {} sub_opt_defs = [] if self.included(vendor): opt_def = self.option_def(vendor) if 'sub-options' in opt_def.keys(): sub_opt_defs = opt_def['sub-options'] else: logging.error(f'No parent-option for vendor {vendor}') else: logging.error(f'Vendor: {vendor} not defined') return sub_opt_defs
[docs] def dump_vendor_def(self, vendor): ''' Returns the vendor definition as a dict ''' vendor_def = {} if self.included(vendor): vendor_def = self.config['vendors'][vendor] return vendor_def