diff --git a/.gitignore b/.gitignore index 894a44c..ec974af 100644 --- a/.gitignore +++ b/.gitignore @@ -102,3 +102,7 @@ venv.bak/ # mypy .mypy_cache/ + +# trash +.vscode/ + diff --git a/README.md b/README.md index 44e060a..4d6e694 100644 --- a/README.md +++ b/README.md @@ -1,53 +1,47 @@ # netbox-scanner -A scanner util for [NetBox](https://netbox.readthedocs.io/en/stable/), because certain networks can be updated automagically. `netbox-scanner` aims to create, update, and delete hosts (`/32`) in NetBox, either discovered after network probes and imported from CSV files. +A scanner util for [NetBox](https://netbox.readthedocs.io/en/stable/), because certain networks can be updated automagically. netbox-scanner aims to create, update, and delete hosts (`/32`) in NetBox, either discovered after network scans and synchronized from other sources. ## Installation -`netbox-scanner` is compatible with Python 3.6+, and can be installed like this: +netbox-scanner is compatible with **Python 3.7+**, and can be installed like this: $ wget https://github.com/forkd/netbox-scanner/archive/master.zip $ unzip netbox-scanner-master.zip -d netbox-scanner $ cd netbox-scanner $ pip install -r requirements.txt - $ python netbox-scanner/nbscanner - -Note that `netbox-scanner` will require [Nmap](https://nmap.org/) and an instance of NetBox (tested under NetBox 2.4.4) ready to use. -## Scanning the Network -To use `netbox-scanner` as a script, simply run `netbox-scanner/nbscanner` and it'll create its configuration file (`.netbox-scanner.conf`) in your home folder: +## Basics +netbox-scanner reads a user-defined source to discover IP addresses and descriptions, and insert them into NetBox. To control what was previously inserted, netbox-scanner adds tags to each record, so it will know that that item can be handled. In order to guarantee the integrity of manual inputs, records without such tags will not be updated or removed. - $ python nbscanner - -After that, you'll just need to edit that file with your environment settings and run the script again, and `netbox-scanner` will do the following tasks: - -1. It will scan all networks defined in the configuration file. -2. For each discovered host it will: - 1. If it is in NetBox, description is different, and `tag` is equal to that defined in the configuration file, description will be updated in NetBox. - 2. If host is not in NetBox, it'll be created. -3. The script will iterate through each network to find and delete any hosts registered in NetBox that did not respond to scan, and have the `tag` defined in the configuration file. - -For instance, if some hosts in your monitored networks are eventually down, but you don't want `netbox-scanner` to manage them, just make sure that they **don't** have the tag defined in the configuration file. - -Of course, you can use `cron` to automatically run `nbscanner`. +It is important to note that if netbox-scanner cannot define the description for a given host, then it will insert the string defined in the `unknown` parameter. Users can change those names at their own will. -## Configuration File -`netbox-scanner` have a configuration file with all parameters needed to scan networks and synchronize them to NetBox. By default, this file is located at user's home folder and is created when `nbscanner` is executed for the first time. Before using `nbscanner` you should edit that file and fill all variables according to your environment. +## Configuration +Users can interact with netbox-scanner by command line and configuration file. The latter is pretty simple and straight forward: the only parameter accepted is the module you want to use. + +The configuration file (`netbox-scanner.conf`) is where netbox-scanner looks for details such as authentication data and path to files. This file can be stored on the user's home directory or on `/opt/netbox`, but if you choose the first option, it must be a hidden file --`.netbox-scanner.conf`. + + Remember that netbox-scanner will always look for this file at home directory, then at `/opt/netbox`, in this order. The first occurrence will be considered. -## Importing from CSV File -`netbox-scanner` can import data from CSV files in the following format: +## Modules +Since version 2.0, netbox-scanner is based on modules. This way, this program is just a layer that takes data from one source and inputs in NetBox. Each module is a file inside the `nbs` directory and is imported by the main script to retrieve data. This data comes **always** as a 2-dimension array of tuple IP address, description: - IP Address,Description - 10.0.0.1,Gateway - 10.0.0.2,NTP Server - ... +```python +[('10.0.1.1', 'Gateway'), ('10.0.1.2', 'Server'), ('10.0.1.64', 'Workstation'), ...] +``` -Note that the first line is header, IP addresses aren't in CIDR notation (a `/32` will be appended to all addresses automatically) and commas aren't accepted in description. You can import this file using the `--csv` parameter, like this: - $ nbscanner --csv netbox.csv +## Nmap Module +Performing the scans is beyond netbox-scanner features, so you must run Nmap and save the output as an XML file using the `-oX` parameter. Since this file can grow really fast, you can scan each network and save it as a single XML file. You just have to assure that all files are under the same directory before running the script --see `samples/nmap.sh` for an example. + +To properly setup this module, you must inform the path to the directory where the XML files reside, define a tag to insert to discovered hosts, and decide if clean up will take place. + + +## Prime Module +To be written. ## License -`netbox-scanner` is licensed under a MIT license --read `LICENSE` file for more information. +`netbox-scanner` is licensed under an MIT license --read `LICENSE` file for more information. diff --git a/nbs/__init__.py b/nbs/__init__.py new file mode 100644 index 0000000..288df69 --- /dev/null +++ b/nbs/__init__.py @@ -0,0 +1,85 @@ +import logging + +from pynetbox import api + + +class NetBoxScanner(object): + + def __init__(self, netbox, hosts, tag, cleanup): + self.netbox = api( + netbox['address'], + netbox['token'], + ssl_verify=netbox.getboolean('tls_verify') + ) + self.hosts = hosts + self.tag = tag + self.cleanup = cleanup + self.stats = { + 'unchanged': 0, + 'created': 0, + 'updated': 0, + 'deleted': 0, + 'errors': 0 + } + + def sync_host(self, host): + '''Syncs a single host to NetBox. + + host: a tuple like ('10.0.0.1','Gateway') + returns: True if syncing is good or False for errors + ''' + try: + nbhost = self.netbox.ipam.ip_addresses.get(address=host[0]) + except ValueError: + logging.error(f'duplicated: {host[0]}/32') + self.stats['errors'] += 1 + return False + + if nbhost: + if (self.tag in nbhost.tags): + if (host[1] != nbhost.description): + aux = nbhost.description + nbhost.description = host[1] + nbhost.save() + logging.info(f'updated: {host[0]}/32 "{aux}" -> "{host[1]}"') + self.stats['updated'] += 1 + else: + logging.info(f'unchanged: {host[0]}/32 "{host[1]}"') + self.stats['unchanged'] += 1 + else: + logging.info(f'unchanged: {host[0]}/32 "{host[1]}"') + self.stats['unchanged'] += 1 + else: + self.netbox.ipam.ip_addresses.create( + address=host[0], + tags=[self.tag], + description=host[1] + ) + logging.info(f'created: {host[0]}/32 "{host[1]}"') + self.stats['created'] += 1 + + return True + + def sync(self): + '''Synchronizes self.hosts to NetBox. + Returns synching statistics. + ''' + for s in self.stats: + self.stats[s] = 0 + + logging.info('started: {} hosts'.format(len(self.hosts))) + for host in self.hosts: + self.sync_host(host) + + if self.cleanup: + pass + + logging.info('finished: +{} ~{} -{} !{}'.format( + self.stats['unchanged'], + self.stats['created'], + self.stats['updated'], + self.stats['deleted'], + self.stats['errors'] + )) + + return True diff --git a/nbs/nmap.py b/nbs/nmap.py new file mode 100644 index 0000000..0d7d413 --- /dev/null +++ b/nbs/nmap.py @@ -0,0 +1,31 @@ +import os +import xml.etree.ElementTree as ET + + +class Nmap(object): + + def __init__(self, path, unknown): + self.unknown = unknown + self.path = path + self.hosts = list() + + def run(self): + for f in os.listdir(self.path): + if not f.endswith('.xml'): + continue + abspath = os.path.join(self.path, f) + tree = ET.parse(abspath) + root = tree.getroot() + + for host in root.findall('host'): + try: + self.hosts.append(( + host.find('address').attrib['addr'], + host.find('hostnames').find('hostname').attrib['name'] + )) + except AttributeError: + self.hosts.append(( + host.find('address').attrib['addr'], + self.unknown + )) + \ No newline at end of file diff --git a/netbox-scanner.conf b/netbox-scanner.conf new file mode 100644 index 0000000..a813ceb --- /dev/null +++ b/netbox-scanner.conf @@ -0,0 +1,19 @@ +[NETBOX] +address = https://netbox.domain +token = +tls_verify = no +logs = . + +[NMAP] +path = samples/nmap +unknown = autodiscovered:netbox-scanner +tag = nmap +cleanup = yes + +[PRIME] +address = https://prime.domain +username = +password = +unknown = autodiscovered:netbox-scanner +tag = prime +cleanup = yes diff --git a/netbox-scanner.py b/netbox-scanner.py new file mode 100644 index 0000000..8b034a9 --- /dev/null +++ b/netbox-scanner.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 + +import logging + +from configparser import ConfigParser +from argparse import ArgumentParser +from os.path import expanduser, isfile +from datetime import datetime +from urllib3 import disable_warnings +from urllib3.exceptions import InsecureRequestWarning + +from nbs import NetBoxScanner +from nbs.nmap import Nmap + + +local_config = expanduser('~/.netbox-scanner.conf') +global_config = '/opt/netbox/netbox-scanner.conf' +config = ConfigParser() + +if isfile(local_config): + config.read(local_config) +elif isfile(global_config): + config.read(global_config) +else: + raise FileNotFoundError('Configuration file was not found.') + +netbox = config['NETBOX'] +nmap = config['NMAP'] +prime = config['PRIME'] + +parser = ArgumentParser(description='netbox-scanner') +subparsers = parser.add_subparsers(title='Commands', dest='command') +subparsers.required = True +argsp = subparsers.add_parser('nmap', help='Nmap module') +argsp = subparsers.add_parser('prime', help='Cisco Prime module') +args = parser.parse_args() + +logfile = '{}/netbox-scanner-{}.log'.format( + netbox['logs'], + datetime.now().isoformat() +) +logging.basicConfig( + filename=logfile, + level=logging.INFO, + format='%(asctime)s\tnetbox-scanner\t%(levelname)s\t%(message)s' +) +logging.getLogger().addHandler(logging.StreamHandler()) + +disable_warnings(InsecureRequestWarning) + + +def cmd_nmap(): # nmap handler + h = Nmap(nmap['path'], nmap['unknown']) + h.run() + print(len(h.hosts));exit(0) + scan = NetBoxScanner( + netbox, + Nmap(nmap['path'], nmap['unknown']).run(), + nmap['tag'], + nmap.getboolean('cleanup') + ) + scan.sync() + +def cmd_prime(): # prime handler + pass + + +if __name__ == '__main__': + if args.command == 'nmap': cmd_nmap() + elif args.command == 'prime': cmd_prime() + exit(0) diff --git a/netbox-scanner/__init__.py b/netbox-scanner/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/netbox-scanner/dac.py b/netbox-scanner/dac.py deleted file mode 100644 index d93817d..0000000 --- a/netbox-scanner/dac.py +++ /dev/null @@ -1,36 +0,0 @@ -# This script can be used sanitize the list of networks -# to be scanned. In practice, (almost) the same routine -# is executed everytime NetBoxScanner().sync() is -# called, but if you want to debug this list or even -# keep a more organized version, you can use dac.py. -## - -import re - -from config import NETWORKS - - -def parser(networks): - '''Parses a list of networks in CIDR notation. - - :param networks: a list of networks like ['10.0.0.0/8',...] - :return: False if parsing is OK, or a string with duplicated - or mistyped networks. - ''' - ipv4 = re.compile(r'^((2([0-4][0-9]|5[0-5])|1?[0-9]?[0-9])\.){3}(2([0-4][0-9]|5[0-5])|1?[0-9]?[0-9])\/(3[012]|[12]?[0-9])$') - duplicated = set([x for x in networks if networks.count(x)>1]) - if duplicated: - return ', '.join(duplicated) - for net in networks: - if not re.match(ipv4, net): - return net - return False - -nets = NETWORKS -nets.sort() - -p = parser(nets) -if not p: - print(nets) -else: - print('ERROR: {}'.format(p)) diff --git a/netbox-scanner/nbscanner b/netbox-scanner/nbscanner deleted file mode 100644 index 7ede4af..0000000 --- a/netbox-scanner/nbscanner +++ /dev/null @@ -1,78 +0,0 @@ -#!/usr/bin/env python3 - -import logging - -from configparser import ConfigParser -from argparse import ArgumentParser -from os import fsync -from os.path import expanduser -from datetime import datetime -from urllib3 import disable_warnings -from urllib3.exceptions import InsecureRequestWarning - -from nbscanner import NetBoxScanner - - -template = ''' -[GENERAL] -tag = auto -unknown = unknown host -log = . -nmap_args = -T4 -O -F --host-timeout 30s - -[NETBOX] -address = https:// -token = -tls_verify = True - -[TACACS] -user = netbox -password = -command = show run | inc hostname -regex = hostname ([A-Z|a-z|0-9|\-|_]+) -regroup = 1 - -[SCAN] -networks = 10.1.2.3/24,10.2.3.4/24 -''' -conffile = expanduser('~/.netbox-scanner.conf') - -try: - config = ConfigParser() - config.read(conffile) - general_conf = config['GENERAL'] - netbox_conf = config['NETBOX'] - networks = config['SCAN']['networks'].split(',') - tacacs_conf = dict() - for key in config['TACACS']: - tacacs_conf[key] = config['TACACS'][key] - tacacs_conf['regroup'] = int(tacacs_conf['regroup']) -except KeyError: - with open(conffile,'w+') as f: - f.write(template) - fsync(f) - print('Config file was created at {}'.format(conffile)) - print('Fill all fields before run the script again.') - exit(1) - -argp = ArgumentParser() -argp.add_argument('-c', '--csv', - help='import CSV file instead of scan network', default=None) -args = argp.parse_args() - -logfile = '{}/netbox-scanner-{}.log'.format(general_conf['log'], - datetime.now().strftime('%Y%m%dT%H%M%SZ')) -logging.basicConfig(filename=logfile, level=logging.INFO, - format='%(asctime)s\tnetbox-scanner\t%(levelname)s\t%(message)s') -disable_warnings(InsecureRequestWarning) - - -if __name__ == '__main__': - nbs = NetBoxScanner(netbox_conf['address'], netbox_conf['token'], - netbox_conf.getboolean('tls_verify'), general_conf['nmap_args'], - tacacs_conf, general_conf['tag'], general_conf['unknown']) - if not args.csv: - nbs.sync(networks) - else: - nbs.sync_csv(args.csv) - exit(0) diff --git a/netbox-scanner/nbscanner.py b/netbox-scanner/nbscanner.py deleted file mode 100644 index a55a954..0000000 --- a/netbox-scanner/nbscanner.py +++ /dev/null @@ -1,232 +0,0 @@ -import re -import logging - -from ipaddress import IPv4Network - -from nmap import PortScanner -from cpe import CPE -from csv import reader -from pynetbox import api -from paramiko import SSHClient, AutoAddPolicy -from paramiko.ssh_exception import AuthenticationException -from paramiko.ssh_exception import SSHException -from paramiko.ssh_exception import NoValidConnectionsError - - -logging.getLogger('paramiko').setLevel(logging.CRITICAL) # paramiko is noisy - - -class NetBoxScanner(object): - - def __init__(self, address, token, tls_verify, nmap_args, tacacs, tag, - unknown): - self.netbox = api(address, token=token, ssl_verify=tls_verify) - self.nmap_args = nmap_args - self.tacacs = tacacs - self.tag = tag - self.unknown = unknown - self.stats = {'created':0, 'updated':0, 'deleted':0, - 'undiscovered':0, 'duplicated':0} - - def parser(self, networks): - '''Parses a list of networks in CIDR notation. - - :param networks: a list of networks like ['10.0.0.0/8',...] - :return: False if parsing is OK, or a string with duplicated - or mistyped networks. - ''' - ipv4 = re.compile(r'^((2([0-4][0-9]|5[0-5])|1?[0-9]?[0-9])\.){3}(2([0-4][0-9]|5[0-5])|1?[0-9]?[0-9])\/(3[012]|[12]?[0-9])$') - duplicated = set([x for x in networks if networks.count(x)>1]) - if duplicated: - return ', '.join(duplicated) - for net in networks: - if not re.match(ipv4, net): - return net - return False - - def get_networks(self): - '''Retrieves all networks/prefixes recorded into NetBox.''' - return [str(net) for net in self.netbox.ipam.prefixes.all()] - - def get_description(self, address, name, cpe): - '''Define a description based on hostname and CPE''' - if name: - return name - else: - c = CPE(cpe[0], CPE.VERSION_2_3) - vendor = c.get_vendor()[0] - if self.tacacs and vendor == 'cisco': - try: - client = SSHClient() - client.set_missing_host_key_policy(AutoAddPolicy()) - client.connect(address, username=self.tacacs['user'], - password=self.tacacs['password']) - stdin,stdout,stderr = client.exec_command(self.tacacs['command']) - return '{}:{}'.format(vendor.lower(), - re.search(self.tacacs['regex'], - str(stdout.read().decode('utf-8'))).group(self.tacacs['regroup'])) - except (AuthenticationException, SSHException, - NoValidConnectionsError, TimeoutError, - ConnectionResetError): - pass - return '{}.{}.{}'.format(c.get_vendor()[0], c.get_product()[0], - c.get_version()[0]) - - def scan(self, network): - '''Scan a network. - - :param network: a valid network, like 10.0.0.0/8 - :return: a list of tuples like [('10.0.0.1','Gateway'),...]. - ''' - hosts = [] - nm = PortScanner() - nm.scan(network, arguments=self.nmap_args) - - for host in nm.all_hosts(): - address = nm[host]['addresses']['ipv4'] - try: - description = self.get_description( - address, nm[host]['hostnames'][0]['name'], - nm[host]['osmatch'][0]['osclass'][0]['cpe']) - except (KeyError, AttributeError, IndexError, - NotImplementedError): - description = self.unknown - hosts.append((address, description)) - return hosts - - def logger(self, logtype, **kwargs): - '''Logs and updates stats for NetBox interactions.''' - if logtype == 'scanned': - logging.info('scanned: {} ({} hosts discovered)'.format(kwargs['net'], kwargs['hosts'])) - elif logtype == 'created': - logging.info('created: {}/32 "{}"'.format(kwargs['address'], - kwargs['description'])) - self.stats['created'] += 1 - elif logtype == 'updated': - logging.warning('updated: {}/32 "{}" -> "{}"'.format( - kwargs['address'], kwargs['description_old'], - kwargs['description_new'])) - self.stats['updated'] += 1 - elif logtype == 'deleted': - logging.warning('deleted: {} "{}"'.format(kwargs['address'], - kwargs['description'])) - self.stats['deleted'] += 1 - elif logtype == 'undiscovered': - logging.warning('undiscovered: {} "{}"'.format(kwargs['address'], - kwargs['description'])) - self.stats['undiscovered'] += 1 - elif logtype == 'duplicated': - logging.error('duplicated: {}/32'.format(kwargs['address'])) - self.stats['duplicated'] += 1 - elif logtype == 'mistyped': - logging.error('mistyped: {}'.format(kwargs['badnets'])) - - def sync_host(self, host): - '''Syncs a single host to NetBox. - - :param host: a tuple like ('10.0.0.1','Gateway') - :return: True if syncing is ok or False in other case. - ''' - try: - nbhost = self.netbox.ipam.ip_addresses.get(address=host[0]) - except ValueError: - self.logger('duplicated', address=host[0]) - return False - if nbhost: - if (self.tag in nbhost.tags) and (host[1] != nbhost.description): - aux = nbhost.description - nbhost.description = host[1] - nbhost.save() - self.logger('updated', address=host[0], description_old=aux, - description_new=host[1]) - else: - self.netbox.ipam.ip_addresses.create(address=host[0], - tags=[self.tag], description=host[1]) - self.logger('created', address=host[0], description=host[1]) - return True - - def sync_network(self, network): - '''Syncs a single network to NetBox. - - :param network: a network with CIDR like '10.0.0.1/24' - :return: True if syncing is ok or False in other case. - ''' - hosts = self.scan(network) - self.logger('scanned', net=network, hosts=len(hosts)) - for host in hosts: - self.sync_host(host) - - ips = list() - ips.append(self.netbox.ipam.ip_addresses.all()) - - for ipv4 in IPv4Network(network): # cleanup - address = str(ipv4) - if any(ip == address for ip in ips): - if not any(h[0]==address for h in hosts): - try: - nbhost = self.netbox.ipam.ip_addresses.get(address=address) - if self.tag in nbhost.tags: - nbhost.delete() - self.logger('deleted', address=nbhost.address, - description=nbhost.description) - else: - self.logger('undiscovered', address=nbhost.address, - description=nbhost.description) - except (AttributeError, ValueError): - pass - return True - - def sync_csv(self, csvfile): - '''Imports a CSV file to NetBox. - - :param csvfile: a CSV file with the following format: - IP addr,Description - 10.0.0.1,Gateway - 10.0.0.2,Server - ... - Note that this CSV file doesn't expect mask on - IP addresses, because all of them are processed - as /32. - :return: True if syncing is ok or False in other case. - ''' - hosts = [] - with open(csvfile,'r') as f: - next(f) - hosts = [(data[0],data[1]) for data in - reader(f,delimiter=',')] - - for s in self.stats: - self.stats[s] = 0 - parsing = self.parser([f'{h[0]}/32' for h in hosts]) - if parsing: - self.logger('mistyped', badnets=parsing) - return False - - logging.info('started: {} hosts via CSV'.format(len(hosts))) - for host in hosts: - self.sync_host(host) - logging.info('finished: +{} ~{} -{} ?{} !{}'.format( - self.stats['created'], self.stats['updated'], - self.stats['deleted'], self.stats['undiscovered'], - self.stats['duplicated'])) - - def sync(self, networks): - '''Scan some networks and sync them to NetBox. - - :param networks: a list of valid networks, like ['10.0.0.0/8'] - :return: synching statistics - ''' - for s in self.stats: - self.stats[s] = 0 - parsing = self.parser(networks) - if parsing: - self.logger('mistyped', badnets=parsing) - return False - - logging.info('started: {} networks'.format(len(networks))) - for network in networks: - self.sync_network(network) - logging.info('finished: +{} ~{} -{} ?{} !{}'.format( - self.stats['created'], self.stats['updated'], self.stats['deleted'], - self.stats['undiscovered'], self.stats['duplicated'])) - return True diff --git a/requirements.txt b/requirements.txt index b587f5b..c4120ab 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,17 +1,7 @@ -asn1crypto==0.24.0 -bcrypt==3.1.6 -certifi==2019.3.9 -cffi==1.12.2 -chardet==3.0.4 -cpe==1.2.1 -cryptography==2.6.1 -idna==2.8 -paramiko==2.4.2 -pyasn1==0.4.5 -pycparser==2.19 -PyNaCl==1.3.0 -pynetbox==4.0.6 -python-nmap==0.6.1 -requests==2.21.0 -six==1.12.0 -urllib3==1.24.2 +certifi==2020.4.5.1 +chardet==3.0.4 +idna==2.9 +pynetbox==4.3.1 +requests==2.23.0 +six==1.15.0 +urllib3==1.25.9 diff --git a/samples/nmap-scan.sh b/samples/nmap-scan.sh new file mode 100644 index 0000000..1aaafe6 --- /dev/null +++ b/samples/nmap-scan.sh @@ -0,0 +1,28 @@ +#!/bin/sh +# +# This is just an example. +# +# Since scanning many networks can produce huge XML files, +# the idea is to create one XML file per network, then +# use all of them as input to nbs.nmap.Nmap(). +# +# If you scan few networks with few hosts or if you just +# want to experiment, feel free to use the `-iL` option of +# Nmap, passing a list of all networks and hosts to be +# scanned. +# +# For the purpose of this example, assume that netbox-scanner +# is configured to use the same directory of this script +# to look for XML files. +## + +NETWORKS = "10.1.2.3/24 10.2.3.4/32 192.168.0.0/19" +TODAY="$(date +%d.%m.%yT%H:%M:%S%Z)" + +for net in $NETWORKS; do + nmap -T4 -O -F --host-timeout 30s -oX nmap-"$net".xml +done + +python ../netbox-scanner.py nmap +tar -czvf nmap-"$TODAY".tar.gz *.xml +rm -rf *.xml diff --git a/setup.py b/setup.py deleted file mode 100644 index 9c9dfcd..0000000 --- a/setup.py +++ /dev/null @@ -1,23 +0,0 @@ -#!/usr/bin/env python3 - -import setuptools - -with open('README.md', 'r') as fh: - long_description = fh.read() - -setuptools.setup( - name='netbox-scanner', - version='0.7.2', - author='José Lopes de Oliveira Jr.', - author_email='jlojunior@gmail.com', - description='A scanner util for NetBox', - long_description=long_description, - long_description_content_type='text/markdown', - url='https://github.com/forkd/netbox-scanner', - packages=setuptools.find_packages(), - classifiers=[ - 'Programming Language :: Python :: 3', - 'License :: OSI Approved :: MIT License', - 'Operating System :: OS Independent', - ], -)