#!/usr/local/bin/python3
# vim: tabstop=8 expandtab shiftwidth=4 softtabstop=4
'''
------------------------------------------------------------------------
Description:
Module to provide class hierachy to simplify access to the BloxOne APIs
Date Last Updated: 20220929
Todo:
api_key format verification upon inifile read.
Copyright (c) 2020-2022 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.
------------------------------------------------------------------------
'''
import logging
import configparser
import requests
import os
import re
# ** Global Vars **
__version__ = '0.8.13'
__author__ = 'Chris Marrison'
__email__ = 'chris@infoblox.com'
__doc__ = 'https://python-bloxone.readthedocs.io/en/latest/'
__license__ = 'BSD'
# Custom Exceptions
[docs]class IniFileSectionError(Exception):
'''
Exception for missing section in ini file
'''
pass
[docs]class IniFileKeyError(Exception):
'''
Exception for missing key in ini file
'''
pass
# ** Facilitate ini file for basic configuration including API Key
[docs]def read_b1_ini(ini_filename):
'''
Open and parse ini file
Parameters:
ini_filename (str): name of inifile
Returns:
config (dict): Dictionary of BloxOne configuration elements
Raises:
IniFileSectionError
IniFileKeyError
APIKeyFormatError
FileNotFoundError
'''
# Local Variables
cfg = configparser.ConfigParser()
config = {}
ini_keys = ['url', 'api_version', 'api_key']
# Check for inifile and raise exception if not found
if os.path.isfile(ini_filename):
# Attempt to read api_key from ini file
try:
cfg.read(ini_filename)
except configparser.Error as err:
logging.error(err)
# Look for BloxOne section
if 'BloxOne' in cfg:
for key in ini_keys:
# Check for key in BloxOne section
if key in cfg['BloxOne']:
config[key] = cfg['BloxOne'][key].strip("'\"")
logging.debug('Key {} found in {}: {}'
.format(key, ini_filename, config[key]))
else:
logging.error('Key {} not found in BloxOne section.'
.format(key))
raise IniFileKeyError('Key "' + key + '" not found within'
'[BloxOne] section of ini file {}'.format(ini_filename))
else:
logging.error('No BloxOne Section in config file: {}'
.format(ini_filename))
raise IniFileSectionError('No [BloxOne] section found in ini file {}'
.format(ini_filename))
else:
raise FileNotFoundError('ini file "{}" not found.'.format(ini_filename))
return config
[docs]def verify_api_key(apikey):
'''
Verify format of API Key
Parameters:
apikey (str): api key
Returns:
bool: True is apikey passes format validation
'''
if re.fullmatch('[a-z0-9]{32}|[a-z0-9]{64}', apikey, re.IGNORECASE):
status = True
else:
status = False
return status
[docs]class b1:
'''
Parent Class to simplify access to the BloxOne APIs for subclasses
Can also be used to genericallly access the API
Raises:
IniFileSectionError
IniFileKeyError
APIKeyFormatError
FileNotFoundError
'''
def __init__(self, cfg_file='config.ini',
api_key='',
url='https://csp.infoblox.com',
api_version='v1'):
'''
Read ini file and set attributes
Parametrers:
cfg_file (str): Override default ini filename
api_key (str): Use API Key instead of ini
url (str): Override URL, applies only if api_key specified
api_version (str): API version, applies only if api_key specified
'''
self.cfg = {}
if api_key:
self.api_key = api_key
# Create base URLs
self.base_url = url
self.api_version = api_version
else:
# Read ini file
self.cfg = read_b1_ini(cfg_file)
self.api_key = self.cfg['api_key']
# Create base URLs
self.base_url = self.cfg['url']
self.api_version = self.cfg['api_version']
# Verify format of API Key
if verify_api_key(self.api_key):
logging.debug('API Key passed format verification')
else:
logging.debug('API Key {} failed format verification'
.format(self.api_key))
raise APIKeyFormatError('API Key {} failed format verification'
.format(self.api_key))
# Define generic header
self.headers = ( {'content-type': 'application/json',
'Authorization': 'Token ' + self.api_key} )
# B1 & B1DDI URLs
self.anycast_url = self.base_url + '/api/anycast/' + self.api_version
self.authn_url = self.base_url + '/api/authn/' + self.api_version
self.bootstrap_url = self.base_url + '/bootstrap-app/' + self.api_version
self.cdc_url = self.base_url + '/api/cdc-flow/' + self.api_version
self.diagnostics_url = self.base_url + '/diagnostic-service/' + self.api_version
self.ddi_url = self.base_url + '/api/ddi/' + self.api_version
self.host_url = self.base_url + '/api/host_app/' + self.api_version
self.notifications_url = self.base_url + '/atlas-notifications-config/'+ self.api_version
self.sw_url = self.base_url + '/api/upgrade_policy/' + self.api_version
self.ztp_url = self.base_url + '/atlas-host-activation/' + self.api_version
# B1TD URLs
self.tdc_url = self.base_url + '/api/atcfw/' + self.api_version
self.tdep_url = self.base_url + '/api/atcep/' + self.api_version
self.tddfp_url = self.base_url + '/api/atcdfp/' + self.api_version
self.tdlad_url = self.base_url + '/api/atclad/' + self.api_version
self.tide_url = self.base_url + '/tide/api'
self.dossier_url = self.base_url + '/tide/api/services/intel/lookup'
self.threat_enrichment_url = self.base_url + '/tide/threat-enrichment'
# Reporting URLs
self.ti_reports_url = self.base_url + '/api/ti-reports/' + self.api_version
self.aggr_reports_url = self.ti_reports_url + '/activity/aggregations'
self.insights_url = self.aggr_reports_url + '/insight'
self.sec_act_url = self.base_url + '/api/ti-reports/v1/activity/hits'
# List of successful return codes
self.return_codes_ok = [200, 201, 204]
return
def _add_params(self, url, first_param=True, **params):
# Add params to API call URL
if len(params):
for param in params.keys():
if first_param:
url = url + '?'
first_param = False
else:
url = url + '&'
url = url + param + '=' + params[param]
return url
def _apiget(self, url):
# Call BloxOne API
try:
response = requests.request("GET",
url,
headers=self.headers)
# Catch exceptions
except requests.exceptions.RequestException as e:
logging.error(e)
logging.debug("url: {}".format(url))
raise
# Return response code and body text
# return response.status_code, response.text
return response
def _apipost(self, url, body, headers=""):
# Set headers
if not headers:
headers = self.headers
# Call BloxOne API
try:
response = requests.request("POST",
url,
headers=headers,
data=body)
# Catch exceptions
except requests.exceptions.RequestException as e:
logging.error(e)
logging.debug("url: {}".format(url))
logging.debug("body: {}".format(body))
raise
# Return response code and body text
return response
def _apidelete(self, url, body=""):
# Call BloxOne API
try:
response = requests.request("DELETE",
url,
headers=self.headers,
data=body)
# Catch exceptions
except requests.exceptions.RequestException as e:
logging.error(e)
logging.debug("URL: {}".format(url))
logging.debug("BODY: {}".format(body))
raise
# Return response code and body text
return response
def _apiput(self, url, body):
# Call BloxOne API
try:
response = requests.request("PUT",
url,
headers=self.headers,
data=body)
# Catch exceptions
except requests.exceptions.RequestException as e:
logging.error(e)
logging.debug("url: {}".format(url))
logging.debug("body: {}".format(body))
raise
# Return response code and body text
return response
def _apipatch(self, url, body):
# Call BloxOne API
try:
response = requests.request("PATCH",
url,
headers=self.headers,
data=body)
# Catch exceptions
except requests.exceptions.RequestException as e:
logging.error(e)
logging.debug("url: {}".format(url))
logging.debug("body: {}".format(body))
raise
# Return response code and body text
return response
def _use_obj_id(self, url, id="", action=""):
'''
Update URL for use with object id
Parameters:
id (str): Bloxone Object id
action (str): e.g. nextavailableip
Returns:
string : Updated url
'''
# Check for id and next available IP
if id:
url = url + '/' + str(id)
if action:
url = url + '/' + action
else:
if action:
logging.debug("Action {} not supported without "
"a specified object id.")
return url
def _not_found_response(self, b1object='object'):
'''
Generate a response object without an API call
Parameters:
b1object (str): Name of object to use in error
Returns:
requests response object
'''
err_msg = f'{{"error":[{{"message":"{b1object} not found"}}]}}'
response = requests.Response()
response.status_code = 400
response._content = str.encode(err_msg)
return response
# Public Generic Methods
[docs] def get(self, url, id="", action="", **params):
'''
Generic get object wrapper
Parameters:
url (str): Full URL
id (str): Optional Object ID
action (str): Optional object action, e.g. "nextavailableip"
Returns:
response object: Requests response object
'''
# Build url
url = self._use_obj_id(url, id=id, action=action)
url = self._add_params(url, **params)
logging.debug("URL: {}".format(url))
response = self._apiget(url)
return response
[docs] def post(self, url, id="", action="", body="", **params):
'''
Generic Post object wrapper
Parameters:
url (str): Full URL
id (str): Optional Object ID
action (str): Optional object action, e.g. "nextavailableip"
Returns:
response object: Requests response object
'''
# Build url
url = self._use_obj_id(url, id=id, action=action)
url = self._add_params(url, **params)
logging.debug("URL: {}".format(url))
response = self._apipost(url, body)
return response
[docs] def create(self, url, body=""):
'''
Generic create object wrapper
Parameters:
url (str): Full URL
body (str): JSON formatted data payload
Returns:
response object: Requests response object
'''
# Build url
logging.debug("URL: {}".format(url))
# Make API Call
response = self._apipost(url, body)
return response
[docs] def delete(self, url, id="", body=""):
'''
Generic delete object wrapper
Parameters:
url (str): Full URL
id (str): Object id to delete
body (str): JSON formatted data payload
Returns:
response object: Requests response object
'''
# Build url
if id:
url = self._use_obj_id(url,id=id)
logging.debug("URL: {}".format(url))
# Make API Call
response = self._apidelete(url, body=body)
return response
[docs] def update(self, url, id="", body=""):
'''
Generic create object wrapper
Parameters:
url (str): Full URL
body (str): JSON formatted data payload
Returns:
response object: Requests response object
'''
# Build url if needed
url = self._use_obj_id(url, id=id)
logging.debug("URL: {}".format(url))
# Make API Call
response = self._apiput(url, body)
return response
[docs] def replace(self, url, id="", body=""):
'''
Generic create object wrapper
Parameters:
url (str): Full URL
body (str): JSON formatted data payload
Returns:
response object: Requests response object
'''
# Build url
url = self._use_obj_id(url, id=id)
logging.debug("URL: {}".format(url))
# Make API Call
response = self._apipatch(url, body)
return response