python_vpn_audit/main.py

390 lines
25 KiB
Python
Raw Normal View History

2023-08-30 16:33:44 +00:00
#!/usr/bin/env python
import os
import sys
import datetime
import pymongo
import urllib.parse
import argparse
from bson.json_util import dumps, loads
import logging
import datetime
# vpn_audit is not an installable package, update script instance python path to include modules
fpath = os.path.join(os.path.dirname(__file__), 'vpn_audit')
sys.path.append(fpath)
from vpn_audit import *
def inventory_tasks(read_device_collection, read_ip_collection, write_collection):
## retrive all vpn devices from MongoDB
device_dict = device_record(read_device_collection)
## debug, override devices
# target_devices = ['air-oob-hub01', 'air-vpn09', 'zfr-evry-p2p-agg01']
# device_dict = {k:v for k, v in device_dict.items() if k in target_devices}
## retrieve device management address info from MongoDB
device_dict = mgmt_address(read_ip_collection, device_dict)
## DNS lookup for devices in MongoDB without management address info, populate 'unknown' fields on failed lookup
# dont resolve tnsi.com, external dns record endpoints dont typically have accessible ssh
suffix = ['red.tnsi.com', 'corp.tnsi.com', 'blue.tnsi.com', 'open.corp.tnsi.com', 'win2k.corp.tnsi.com', 'reston.tnsi.com', 'csg.tnsi.com', 'tsdlabs.tnsi.com', 'vlab.corp.tnsi.com']
device_dict = dns_lookup(suffix, device_dict)
## debug, pretty print device inventory
# print(json.dumps(device_dict, indent=4))
## write inventory to device table
write_devices_collection(write_collection, device_dict)
## get device os for scrapli driver selection
find_query = { "session_protocol" : { "$exists" : False } }
object_ids = document_ids(write_collection, find_query)
get_os(write_collection, object_ids)
## get cisco version
query_modifier = { "session_protocol" : "ssh", "vendor": "cisco" }
target_devices = device_names(write_collection, query_modifier, device_name_list = [])
def send_commands(collection, device_id, device_name):
## start new buffered logger for thread
logger = logging.getLogger('main')
thread_logger = logging.getLogger(device_name)
thread_logger.setLevel(logging.INFO)
memhandler = logging.handlers.MemoryHandler(1024*10, target=logger, flushOnClose=True)
thread_logger.addHandler(memhandler)
## send commands to device, parse, update collection
commands = {
"cisco_version": {"command": "show version"}
}
for command in commands.keys():
func_ref = eval(command)
commands[command].update({'func_ref': func_ref})
status = device_commands(collection, device_id, commands)
if 'error' in status:
thread_logger.error(status)
# collection['temp'][device_name].drop() # may want to drop temp table
return
## stop buffered logger for thread, flush logs to 'main' logger
memhandler.close()
del thread_logger
threads = 16
with ThreadPoolExecutor(max_workers=threads) as executor:
for d in target_devices:
device_id = target_devices[d]
device_name = d
executor.submit(send_commands, write_collection, device_id, device_name)
# def document_update_tasks(collection, device_name):
def document_update_tasks(read_device_collection, read_ip_collection, write_collection, device_name):
## init
logger = logging.getLogger(device_name)
## dedupe temp device collection
# (there can be near identicle documents where the documents have a different 'c_id' value - maybe tunnel renegotiation)
# logger.info(f"Deduplicate: {collection['temp'][device_name].full_name}")
logger.info(f"Deduplicate: {write_collection['temp'][device_name].full_name}")
# mode = 'list'
# mode = 'show'
mode = 'delete'
ignore_schema_keys = ['_id', 'c_id']
# tmp_collection = collection['temp'][device_name]
tmp_collection = write_collection['temp'][device_name]
# deduplicate_collection(collection['temp'][device_name], mode, ignore_schema_keys)
deduplicate_collection(collection = tmp_collection, mode = mode, ignore_schema_keys = ignore_schema_keys, logger_name = device_name)
## lookup NMS mongo(remedy) spoke devices NMS tickets (get remedy 'DeviceRecNum' for each spoke device)
logger.info(f'Lookup: find peer device ticket {write_collection[device_name].full_name}')
tmp_collection = write_collection['temp'][device_name]
spoke_lookup(read_device_collection = read_device_collection, read_ip_collection = read_ip_collection, write_collection = tmp_collection, logger_name = device_name)
## lookup NMS mongo(remedy) spoke devices NMS info (use remedy 'DeviceRecNum' to get any required device ticket attributes)
logger.info(f'Lookup: find peer device attributes {write_collection[device_name].full_name}')
tmp_collection = write_collection['temp'][device_name]
device_ticket_lookup(read_device_collection = read_device_collection, write_collection = tmp_collection, logger_name = device_name)
## merge temp tables to main device table
# (in short this mask of keys 'tunnel_qualifier_keys' determines if a valid/full tunnel is configured and has been active in a scrape)
# (tunnel_qualifier_keys describes the keys required for a tunnel with cisco_vpn_phase1 + cisco_crypto_session, the keys from cisco_crypto_session are only present when cisco_crypto_map describes a matching cisco_vpn_phase2 configuration)
# (the tunnel may report 'session_status': 'UP-ACTIVE' displaying full phase1/phase2 attributes, 'UP-IDLE' / 'DOWN-NEGOTIATING' will only capture phase1 attributes)
#
# (owing to the phase1/phase2 timout values, stale configuration (and other conditions unknown), scrapes may not capture full tunnel configurations)
# (we may see partial scraped documents with the following data: cisco_vpn_phase1 / cisco_crypto_map / cisco_vpn_phase1 + cisco_crypto_session / cisco_crypto_session / cisco_vpn_phase1 + cisco_crypto_map / cisco_crypto_session + cisco_vpn_phase2)
# (subsequent scrapes may capture tunnel configuration in an active state (likely withing phase1/2 timeout thresholds) and contain enough of the keys to match the 'tunnel_qualifier_keys' mask)
# logger.info(f"Merge: {collection['temp'][device_name].full_name} to {collection[device_name].full_name}")
# src_collection = collection['temp'][device_name]
# dst_collection = collection[device_name]
logger.info(f"Merge: {write_collection['temp'][device_name].full_name} to {write_collection[device_name].full_name}")
src_collection = write_collection['temp'][device_name]
dst_collection = write_collection[device_name]
# ignore specific keys in src_document when searching for matching documents in the dst_collection
# 'crypto_map_interface' is dynamic and only present when data is passing, dont want multiple documents in the dst_collection table for a tunnel with/without this attribute
# 'ipsec_flow' maybe dynamic, changing when devices pass traffic
#ignore_src_schema_keys = ['_id', 'c_id', 'crypto_map_interface', 'ipsec_flow']
ignore_src_schema_keys = ['_id', 'c_id', 'crypto_map_interface', 'ipsec_flow', 'DeviceName', 'Manufacturer', 'Model', 'DeviceRecNum', 'nhrp_nexthop'] # merge records with latest/new scrape fields - 'DeviceName', 'Manufacturer', 'Model', 'DeviceRecNum', 'nhrp_nexthop' - likely not a problem to leave in place
# exclude keys in the insert/update to the dst_collection
exclude_dst_schema_keys = ['_id', 'c_id']
# list of additional key value pairs to add to each document in the dst_collection (the date Type value is interpereted by MongoDB as type ISODate)
additonal_dst_schema_keypairs = [{'last_modified': datetime.datetime.now(tz=datetime.timezone.utc)}]
# list of schema keys to match/qualify document in src_collection for merge to dst_collection (optional parameter, when unused everything gets merged)
tunnel_qualifier_keys = ["local_ip", "p1_ivrf", "peer_ip", "p1_dh_group", "p1_encr_algo", "p1_hash_algo", "p1_auth_type", "p1_status", "local_port", "crypto_session_interface", "peer_port", "p2_fvrf", "peer_vpn_id"]
merge_to_collection(src_collection = src_collection, dst_collection = dst_collection, ignore_src_schema_keys = ignore_src_schema_keys, exclude_dst_schema_keys = exclude_dst_schema_keys, additonal_dst_schema_keypairs = additonal_dst_schema_keypairs, match_src_schema_keys = tunnel_qualifier_keys, logger_name = device_name)
# ## debug - validate merge results, all records in src_collection should be included or excluded from merge - check against merge_to_collection results
# # mode = 'show'
# mode = 'stat'
# ignore_src_schema_keys = ['_id', 'c_id']
# src_collection = collection['temp'][device_name]
# dst_collection = collection[device_name]
# tunnel_qualifier_keys = ["local_ip", "p1_ivrf", "peer_ip", "p1_dh_group", "p1_encr_algo", "p1_hash_algo", "p1_auth_type", "p1_status", "local_port", "crypto_session_interface", "peer_port", "p2_fvrf", "peer_vpn_id"]
# match_src_schema_keys = tunnel_qualifier_keys
# diff_collection(src_collection, dst_collection, mode, ignore_src_schema_keys, match_src_schema_keys)
## full dedupe device table
# (ensure there are no 'duplicate' documents that only differ by '_id')
# (this should not be encountered at this stage)
# (if there are duplicates they must have the same datestamp indicating an error in the previous merge function)
# logger.info(f"Deduplicate: {collection[device_name].full_name}")
logger.info(f"Deduplicate: {write_collection[device_name].full_name}")
# device_collection = collection[device_name]
device_collection = write_collection[device_name]
mode = 'delete'
ignore_schema_keys = ['_id']
deduplicate_collection(collection = device_collection, mode = mode, ignore_schema_keys = ignore_schema_keys, logger_name = device_name)
#deduplicate_collection(collection[device_name], mode, ignore_schema_keys)
## capture 'UP-IDLE' / 'DOWN-NEGOTIATING' tunnels, delete if UP-ACTIVE tunnel documents exist
# (this may occur if an idle tunnel (cisco_vpn_phase1 + cisco_crypto_session) is initially scraped, then the full tunnel establishes +(cisco_vpn_phase2 + cisco_crypto_map) and is captured on a subsequent scrape thus creating two documents)
# (if the tunnel is idle on the 3rd+ scrape it will be merged into the document with the full tunnel attributes)
#
# (the 'idle_connection' mask contains the nearly the same keys as the 'tunnel_qualifier_keys' mask, as listed below. the 'session_status' field is ignored in the query to ensure both idle + active documents are matched)
# (["local_ip", "p1_ivrf", "peer_ip", "p1_dh_group", "p1_encr_algo", "p1_hash_algo", "p1_auth_type", "p1_status", "local_port", "crypto_session_interface", "session_status", "peer_port", "p2_fvrf", "peer_vpn_id"])
# print(f'\ndeduplicate_collection collection[{device_name}] - remove matched idle_connection')
# logger.info(f"Deduplicate: {collection[device_name].full_name} - remove 'UP-IDLE' records that are subset to 'UP-ACTIVE' records")
logger.info(f"Deduplicate: {write_collection[device_name].full_name} - remove 'UP-IDLE' records that are subset to 'UP-ACTIVE' records")
# device_collection = collection[device_name]
device_collection = write_collection[device_name]
mode = 'delete'
ignore_schema_keys = ['_id', 'last_modified', 'session_status']
idle_connection = ["local_ip", "p1_ivrf", "peer_ip", "p1_dh_group", "p1_encr_algo", "p1_hash_algo", "p1_auth_type", "p1_status", "local_port", "crypto_session_interface", "peer_port", "p2_fvrf", "peer_vpn_id"]
#required_schema_keys = idle_connection
#deduplicate_collection(collection[device_name], mode, ignore_schema_keys, required_schema_keys)
deduplicate_collection(collection = device_collection, mode = mode, ignore_schema_keys = ignore_schema_keys, required_schema_keys = idle_connection, logger_name = device_name)
## drop temp sorting table (disable for debug)
write_collection['temp'][device_name].drop()
## want to match >1 documents with the following keys as aggregate id_keys {"$group":{"_id": id_keys,"count": {"$sum": 1}}}
## ["local_ip", "local_port", "peer_ip", "peer_port", "peer_vpn_id"]
## this should match documents that have had changes to their config such as 'ordered_transform_set' / 'p2_encr_algo' and allow for a document date comparrison on the key 'last_modified'
## add an additional key to each document 'current_configuration' with a bool value for inclusion/exclusion in the spreadsheet stats and as a rudamentary history to indicate resolved complaince status
# def audit_tasks(collection, target_devices):
def audit_tasks(read_device_collection, read_ip_collection, write_collection, target_devices):
# def send_commands(collection, device_id, device_name):
def send_commands(read_device_collection, read_ip_collection, write_collection, device_id, device_name):
## start new buffered logger for thread
logger = logging.getLogger('main')
thread_logger = logging.getLogger(device_name)
thread_logger.setLevel(logging.INFO)
memhandler = logging.handlers.MemoryHandler(1024*10, target=logger, flushOnClose=True)
thread_logger.addHandler(memhandler)
try:
## send commands to device, parse, update collection - isakmp / ipsec audit (the order of commands is tied to the db record logic)
commands = {
"cisco_vpn_phase1": {"command": "show crypto isakmp sa detail"},
"cisco_crypto_session": {"command": "show crypto session detail"},
"cisco_vpn_phase2": {"command": "show crypto ipsec sa"},
"cisco_crypto_map": {"command": "show crypto map"},
"cisco_isakmp_policy": {"command": "show crypto isakmp policy"},
"cisco_nhrp_lookup": {"command": "compound"}
}
for command in commands.keys():
func_ref = eval(command)
commands[command].update({'func_ref': func_ref})
# status = device_commands(collection, device_id, commands)
status = device_commands(write_collection, device_id, commands)
if 'error' in status:
thread_logger.error(status)
# collection['temp'][device_name].drop() # may want to drop temp table on error
return
## send commands to device, parse, update collection - 3des audit (the order of commands is tied to the db record logic)
commands = {
"cisco_transform_set": {"command": "compound"},
"triple_des_check": {"command": "compound"}
}
for command in commands.keys():
func_ref = eval(command)
commands[command].update({'func_ref': func_ref})
# status = device_commands(collection, device_id, commands)
status = device_commands(write_collection, device_id, commands)
if 'error' in status:
thread_logger.error(status)
# collection['temp'][device_name].drop() # may want to drop temp table on error
return
## promote qualifying tunnels in 'temp' device collection to device collection
# document_update_tasks(collection, device_name)
document_update_tasks(read_device_collection, read_ip_collection, write_collection, device_name)
except Exception as e:
# as buffered logging is in place to try to collate per device/thread log messages, there is no visibility on crash, flush thread log
memhandler.flush()
logger.error(f"Exception occurred: {type(e).__name__}", exc_info=True)
memhandler.close()
del thread_logger
## stop buffered logger for thread, flush logs to 'main' logger
memhandler.close()
del thread_logger
## main loop - send commands to threadpool
# device_ids = [i for i in target_devices.values()]
# device_names = [n for n in target_devices.keys()]
with ThreadPoolExecutor(max_workers=config.device_threads) as executor:
for d in target_devices:
device_id = target_devices[d]
device_name = d
# executor.submit(send_commands, collection, device_id, device_name)
executor.submit(send_commands, read_device_collection, read_ip_collection, write_collection, device_id, device_name)
def parser_action(args):
## main script logic
logger = logging.getLogger('main')
match args.mode:
case 'inventory':
logger.info('#### Run - argument: inventory ####')
inventory_tasks(args.arg_lookup_device_collection, args.arg_lookup_ip_collection, args.arg_write_device_collection)
case 'list':
logger.info('#### Run - argument: list ####')
query_modifier = { "session_protocol" : "ssh", "vendor": "cisco" }
target_devices = device_names(args.arg_write_device_collection, query_modifier, device_name_list = [])
target_devices_list = [d for d in target_devices.keys()]
if len(target_devices_list) >0:
# print(','.join(target_devices_list))
logger.info(f"{','.join(target_devices_list)}")
else:
# print('device table empty, rerun inventory task')
logger.error('device table empty, rerun inventory task')
case 'audit':
if not args.all_devices and args.devices is None:
print('usage: main.py audit [-h] [-a | -d DEVICES]')
print('main.py audit: error: argument -d/--devices or argument -a/--all_devices required')
quit()
if args.all_devices:
query_modifier = { "session_protocol" : "ssh", "vendor": "cisco" }
target_devices = device_names(args.arg_write_device_collection, query_modifier, device_name_list = [])
# print(dumps(target_devices, indent=4))
logger.info('#### Run - argument: audit -a ####')
# audit_tasks(args.arg_write_device_collection, target_devices)
audit_tasks(args.arg_lookup_device_collection, args.arg_lookup_ip_collection, args.arg_write_device_collection, target_devices)
elif len(args.devices) >0:
device_name_list = [d for d in args.devices.split(',')]
# print(f'target devices\n{device_name_list}')
query_modifier = { "session_protocol" : "ssh", "vendor": "cisco" }
target_devices = device_names(args.arg_write_device_collection, query_modifier, device_name_list)
# print(dumps(target_devices, indent=4))
invalid_devices = [d for d in device_name_list if d not in target_devices.keys()]
if len(invalid_devices) >0:
print(f"device(s) error for {','.join(invalid_devices)}\n")
for d in invalid_devices:
if args.arg_write_device_collection.count_documents({'DeviceName': d}) == 0:
print(f'{d} not in device table, rerun inventory task if you are sure the device exists in remedy')
logger.error(f'{d} not in device table, rerun inventory task if you are sure the device exists in remedy')
else:
result = dumps(args.arg_write_device_collection.find({'DeviceName': d}, {'_id': 0, 'DeviceName': 1, "session_protocol" : 1, "vendor": 1}))
print(f'{d} does not meet audit requirements {result}')
logger.error(f'{d} does not meet audit requirements {result}')
quit()
logger.info(f'#### Run - argument: audit -d device1,device2,deviceN ####')
logger.info('Target devices:')
logger.info(f"{','.join([k for k in target_devices.keys()])}")
# audit_tasks(args.arg_write_device_collection, target_devices)
audit_tasks(args.arg_lookup_device_collection, args.arg_lookup_ip_collection, args.arg_write_device_collection, target_devices)
case 'report':
spreadsheet = './output.xlsx'
devices_dict = device_names(args.arg_write_device_collection) # pass query modifier to filter devices in spreadsheet
#print(dumps(devices_dict, indent=4))
logger.info(f'#### Run - argument: report ####')
build_spreadsheet(args.arg_write_device_collection, devices_dict, spreadsheet)
def main():
#### MongoDB sources
# need some sort of class inherritance setup store the client connection object to instantiate collections owned by the class, then chuck into vpn_mongo and have a dict as the config(that can be kept in json/toml)
## TNS MongoDB client connection
# no firewall rules asblpnxpdev01 -> rstlcnscmgd01:27017 (TNS MongoDB), use quick tunnel
# screen -S nmsmongo
# ssh -o "ServerAliveInterval 60" -L 127.0.0.1:27017:rstlcnscmgd01.open.corp.tnsi.com:27017 tseed@airlcinfjmp01.open.corp.tnsi.com
lookup_mongohost = '127.0.0.1'
lookup_mongoport = 27017
lookup_client = pymongo.MongoClient(f'mongodb://{lookup_mongohost}:{lookup_mongoport}/')
lookup_device_db = lookup_client['jobs']
lookup_device_collection = lookup_device_db['DEVICE_WS']
lookup_ip_collection = lookup_device_db['NT_IPAddress_WS']
## DEV MongoDB client connection
# no firewall rules asblpnxpdev01 -> 172.17.213.136:27017 (DEV MongoDB), use quick tunnel
# screen -S testmongo
# ssh -o "ServerAliveInterval 60" -J airlcinfjmp01.open.corp.tnsi.com -L 127.0.0.1:27018:127.0.0.1:27017 tseed@172.17.213.136
write_mongohost = '127.0.0.1'
write_mongoport = 27018
write_username = urllib.parse.quote_plus('script')
write_password = urllib.parse.quote_plus('install1')
write_client = pymongo.MongoClient(f'mongodb://{write_username}:{write_password}@{write_mongohost}:{write_mongoport}/')
write_vpn_db = write_client['vpn']
write_device_collection = write_vpn_db['devices']
#### Logger
logger = logging.getLogger('main')
logger.setLevel(logging.INFO)
console = logging.StreamHandler()
file = logging.FileHandler("main.log")
logger.addHandler(console)
logger.addHandler(file)
formatter = logging.Formatter(
fmt="%(asctime)s, %(levelname)-8s | %(filename)-15s:%(lineno)-5s | %(threadName)-1s: %(message)s",
datefmt="%Y-%m-%d %H:%M:%S")
console.setFormatter(formatter)
file.setFormatter(formatter)
#### Threading, concurrent device audits
# device_threads
# concurrent devices queries (collect), mostly an IO task with lots of delay
# database tasks are suboptimal retrieving all records for dedupe/merge, consider dropping threads where database performance issues/drops
# scrape_threads
# screen scrapes being processed concurrently, mostly a CPU task with lots of loop/split/regex of screen scrapes
# total threads (nested) - 16 devices * 2 nested scrapes = 32 threads
# set to 1 / 1 for debug
config.scrape_threads = os.cpu_count()
config.device_threads = 32
#### Argument parser, run main script logic in parser_action()
parser = argparse.ArgumentParser(description='Collect VPN tunnel info')
audit = argparse.ArgumentParser(add_help=False)
audit_args = audit.add_mutually_exclusive_group()
report = argparse.ArgumentParser(add_help=False)
audit_args.add_argument('-a', '--all_devices', action='store_true', help='All target devices in the VPN device table, WARNING this may take a full day to complete')
audit_args.add_argument('-d', '--devices', action='store', help='Comma separated list of target devices')
report.add_argument('-e', '--email', action='store', help='Email addresses to send report', required=True)
sp = parser.add_subparsers(required=True)
sp_inventory = sp.add_parser('inventory', help='Query NMS MongoDB to generate VPN device table')
sp_audit = sp.add_parser('audit', parents=[audit], description='Collect tunnel info for target devices, requires argument [-a | -d]', help='Collect tunnel info for target devices')
sp_report = sp.add_parser('report', parents=[report], description='Generate VPN XLSX report, requires argument [-e]', help='Generate VPN XLSX report')
sp_list = sp.add_parser('list', description='Return all target devices in VPN device table', help='Return all target devices in VPN device table')
sp_inventory.set_defaults(func=parser_action, mode='inventory', arg_lookup_device_collection=lookup_device_collection, arg_lookup_ip_collection = lookup_ip_collection, arg_write_device_collection = write_device_collection)
# sp_audit.set_defaults(func=parser_action, mode='audit', arg_write_device_collection = write_device_collection)
sp_audit.set_defaults(func=parser_action, mode='audit', arg_lookup_device_collection=lookup_device_collection, arg_lookup_ip_collection = lookup_ip_collection, arg_write_device_collection = write_device_collection)
sp_report.set_defaults(func=parser_action, mode='report', arg_write_device_collection = write_device_collection)
sp_list.set_defaults(func=parser_action, mode='list', arg_write_device_collection = write_device_collection)
args = parser.parse_args()
args.func(args)
if __name__ == "__main__":
main()