commit d01e9893bc4b60284fa907057efd2d78d861c218 Author: checktheroads Date: Sat Jun 22 19:01:34 2019 -0700 initial diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..92b80cf --- /dev/null +++ b/.gitignore @@ -0,0 +1,124 @@ +test.py +__pycache__/ +*.py[cod] +*$py.class +tests/ + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..494e67f --- /dev/null +++ b/.pylintrc @@ -0,0 +1,545 @@ +# Hyperglass PyLint: Notes +# +# This is a mostly default pylintrc file, generated by PyLint. Only cosmetic parameters have been +# changed, mostly naming-style standards. +# +# Additionally, the "cyclic-import" and "logging-fstring-interpolation" messages have been disabled. +# +# "cyclic-import" was disabled due to the structure of the project; almost all modules rely on or +# pass data back and forth between other modules. +# +# "logging-fstring-interpolation" was disabled due to me thinking it's stupid. I find fstrings +# extremely valuable, and while I could get around this default setting by setting variables for +# each log message, e.g.: +# log_message = f"Error: {var1}, {var2}, {var3}" +# logger.error(log_message) +# I find this to be needlessly obtuse, and therefore log fstrings directly: +# logger.error(f"Error: {var1}, {var2}, {var3}") +# Perhaps this is "incorrect", but it works well and is more elegant, in my uneducated opinion. +# +# "duplicate-code" was disabled due to PyLint complaining about using the same logzero debug +# configuration in two files. Apparently having a consistent logging configuration is "bad". + + +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=print-statement, + parameter-unpacking, + unpacking-in-except, + old-raise-syntax, + backtick, + long-suffix, + old-ne-operator, + old-octal-literal, + import-star-module-level, + non-ascii-bytes-literal, + raw-checker-failed, + bad-inline-option, + locally-disabled, + file-ignored, + suppressed-message, + useless-suppression, + deprecated-pragma, + use-symbolic-message-instead, + apply-builtin, + basestring-builtin, + buffer-builtin, + cmp-builtin, + coerce-builtin, + execfile-builtin, + file-builtin, + long-builtin, + raw_input-builtin, + reduce-builtin, + standarderror-builtin, + unicode-builtin, + xrange-builtin, + coerce-method, + delslice-method, + getslice-method, + setslice-method, + no-absolute-import, + old-division, + dict-iter-method, + dict-view-method, + next-method-called, + metaclass-assignment, + indexing-exception, + raising-string, + reload-builtin, + oct-method, + hex-method, + nonzero-method, + cmp-method, + input-builtin, + round-builtin, + intern-builtin, + unichr-builtin, + map-builtin-not-iterating, + zip-builtin-not-iterating, + range-builtin-not-iterating, + filter-builtin-not-iterating, + using-cmp-argument, + eq-without-hash, + div-method, + idiv-method, + rdiv-method, + exception-message-attribute, + invalid-str-codec, + sys-max-int, + bad-python3-import, + deprecated-string-function, + deprecated-str-translate-call, + deprecated-itertools-function, + deprecated-types-field, + next-method-defined, + dict-items-not-iterating, + dict-keys-not-iterating, + dict-values-not-iterating, + deprecated-operator-function, + deprecated-urllib-function, + xreadlines-attribute, + deprecated-sys-function, + exception-escape, + comprehension-escape, + bad-continuation, + cyclic-import, + logging-fstring-interpolation, + duplicate-code + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[LOGGING] + +# Format style used to check logging format string. `old` means using % +# formatting, while `new` is for `{}` formatting. +logging-format-style=new + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package.. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )??$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=100 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=any + +# Naming style matching correct attribute names. +attr-naming-style=any + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Naming style matching correct class attribute names. +class-attribute-naming-style=snake_case + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Naming style matching correct constant names. +const-naming-style=snake_case + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=yes + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +[STRING] + +# This flag controls whether the implicit-str-concat-in-sequence should +# generate a warning on implicit string concatenation in sequences defined over +# several lines. +check-str-concat-over-line-jumps=no + + +[IMPORTS] + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=7 + +# Maximum number of boolean expressions in an if statement. +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/README.md b/README.md new file mode 100644 index 0000000..15d8aef --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# hyperglass-bird + +hyperglass-bird is a restful API for the BIRD routing stack, for use by [hyperglass](https://github.com/checktheroads/hyperglass). hyperglass-bird ingests an HTTP POST request with JSON data and constructs 1 of 5 shell commands to run based on the passed parameters. For example: + +```json +{ + "query_type": "ping", + "afi": "ipv4", + "source": "192.0.2.1", + "target": "1.1.1.1" +} +``` + +Would construct (by default) `ping -4 -c 5 -I 192.0.2.1 1.1.1.1`, execute the command, and return the output as a string. + +For BGP commands, BIRD's `birdc` and `birdc6` are used to get the output. For example: + +```json +{ + "query_type": "bgp_route", + "afi": "ipv6", + "target": "2606:4700:4700::/48" +} +``` +Would construct (by default) `birdc6 -r show route all where 2606:4700:4700::/48 ~ net`, execute the command, and return the output as a string. + +BGP AS Path and Community queries are converted from "standard" hyperglass-supported syntax to BIRD's syntax: + +```json +{ + "query_type": "bgp_aspath", + "afi": "dual", + "target": "_65000$" +} +``` + +Would construct `birdc -r show route all where bgp_path ~ [= * 65000 =]` and `birdc6 -r show route all where bgp_path ~ [= * 65000 =]` and concatenate the output for both commands. + +## Installation + +Currently, hyperglass-bird has only been tested on Ubuntu Server 18.04. A sample systemd service file is included to run hyperglass-bird as a service. + +### Clone the repository + +```console +$ cd /opt/ +$ git clone https://github.com/checktheroads/hyperglass-bird +``` + +### Install requirements + +```console +$ cd /opt/hyperglass-bird/ +$ pip3 install -r requirements.txt +``` + +### Install systemd service +```console +# cp /opt/hyperglass-bird/hyperglass-bird.service.example /etc/systemd/system/hyperglass-bird.service +# systemctl daemon-reload +# systemctl enable hyperglass-bird +``` + +### Update Permissions + +```console +# chown -R bird:bird /opt/hyperglass-bird +``` + +### Generate API Key +```console +$ cd /opt/hyperglass-bird +$ python3 manage.py generate-key +Your API Key is: B3K1ckWUpwNyFU1F +Your Key Hash is: $pbkdf2-sha256$29000$9T5njNFaS6lVag1B6H2vFQ$mLEbQD5kOAgjfZZ1zEVlrke6wE8vBEHzK.zI.7MOAVo +``` + +Copy the API Key, in this example `B3K1ckWUpwNyFU1F` and add it to `configuration.toml`: + +```toml +[api] +# listen_addr = "*" +# port = 8080 +key = "B3K1ckWUpwNyFU1F" +``` + +If needed, you can uncomment the `listen_addr` or `port` varibales if you need to define a specific listen address or TCP port for hyperglass-bird to run on. For exmaple: + +```toml +[api] +listen_addr = "10.0.1.1" +port = 8001 +key = "B3K1ckWUpwNyFU1F" +``` + +In hyperglass, configure `devices.toml` to use the Key Hash (in this example `$pbkdf2-sha256$29000$9T5njNFaS6lVag1B6H2vFQ$mLEbQD5kOAgjfZZ1zEVlrke6wE8vBEHzK.zI.7MOAVo`) as your FRRouting device's password: + +```toml +[router.'router1'] +address = "10.0.0.1" +asn = "65000" +src_addr_ipv4 = "192.0.2.1" +src_addr_ipv6 = "2001:db8::1" +credential = "bird_api_router1" +location = "pop1" +display_name = "POP 1" +port = "8080" +type = "bird" +proxy = "" + +[credential.'bird_api_router1'] +username = "bird" +password = "$pbkdf2-sha256$29000$9T5njNFaS6lVag1B6H2vFQ$mLEbQD5kOAgjfZZ1zEVlrke6wE8vBEHzK.zI.7MOAVo" +``` + +## Start hyperglass-bird + +```console +# systemctl restart hyperglass-bird +# systemctl status hyperglass-bird +``` + +## Test + +hyperglass-bird should now be active, and you can run a simple test to verify that it is working apart from your main hyperglass implementation: + +```python +>>> import json +>>> import requests +>>> query = '{"query_type": "bgp_route", "afi": "ipv4", "target": "1.1.1.0/24"}' +>>> query_json = json.dumps(query) +>>> headers = {'Content-Type': 'application/json', 'X-API-Key': '$pbkdf2-sha256$29000$m9M6R.j9HwMgJGRs7f0/Jw$5HERwfOIn3P0U/M9t5t04SmgRmTzk3435Lr0duqz07w'} +>>> url = "http://192.168.15.130:8080/bird" +>>> output = requests.post(url, headers=headers, data=query_json) +>>> print(output.text) +``` diff --git a/hyperglass-bird.service.example b/hyperglass-bird.service.example new file mode 100644 index 0000000..e5bc148 --- /dev/null +++ b/hyperglass-bird.service.example @@ -0,0 +1,12 @@ +[Unit] +Description=hyperglass BIRD API +After=network.target + +[Service] +User=bird +Group=bird +WorkingDirectory=/opt/hyperglass-bird +ExecStart=/usr/bin/python3 /opt/hyperglass-bird/hyperglass_bird/hyperglass_bird.py + +[Install] +WantedBy=multi-user.target diff --git a/hyperglass_bird/.gitignore b/hyperglass_bird/.gitignore new file mode 100644 index 0000000..77006b8 --- /dev/null +++ b/hyperglass_bird/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +configuration.toml diff --git a/hyperglass_bird/__init__.py b/hyperglass_bird/__init__.py new file mode 100644 index 0000000..f97586d --- /dev/null +++ b/hyperglass_bird/__init__.py @@ -0,0 +1,4 @@ +"""hyperglass_bird is a BIRD API designed for use with the Hyperglass looking glass""" + +from hyperglass_bird import execute +from hyperglass_bird import configuration diff --git a/hyperglass_bird/configuration.py b/hyperglass_bird/configuration.py new file mode 100644 index 0000000..8281adf --- /dev/null +++ b/hyperglass_bird/configuration.py @@ -0,0 +1,151 @@ +""" +Exports constructed commands and API variables from configuration file based \ +on input query +""" +# Standard Imports +import os +import re +import logging + +# Module Imports +import toml +import logzero +from logzero import logger + +# Project Directories +this_directory = os.path.dirname(os.path.abspath(__file__)) + +# TOML Imports +conf = toml.load(os.path.join(this_directory, "configuration.toml")) + + +def debug_state(): + """Returns string for logzero log level""" + state = conf.get("debug", False) + return state + + +# Logzero Configuration +if debug_state(): + logzero.loglevel(logging.DEBUG) +else: + logzero.loglevel(logging.INFO) + + +def api(): + """Imports & exports configured API parameters from configuration file""" + api_dict = { + "listen_addr": conf["api"].get("listen_addr", "*"), + "port": conf["api"].get("port", 8080), + "key": conf["api"].get("key", 0), + } + return api_dict + + +def bird_version(): + """Get BIRD version from command line, convert version to float for comparision""" + import subprocess + from math import fsum + + # Get BIRD version, convert output to UTF-8 string + ver_string = str( + subprocess.check_output(["bird", "--version"], stderr=subprocess.STDOUT), "utf8" + ) + # Extract numbers from string as list of numbers + verlist_string = re.findall(r"\d+", ver_string) + # Convert last 2 numbers in list to decimals + verlist_string_dec = [ + verlist_string[0], + "." + verlist_string[1], + "." + verlist_string[2], + ] + # Convert number strings to floats, add together to produce whole number as version number + version_sum = fsum([float(number) for number in verlist_string_dec]) + return version_sum + + +class BirdConvert: + """Converts traditional/standard commands to BIRD formatted commands""" + + def __init__(self, target): + self.target = target + + def bgp_aspath(self): + """Takes traditional AS_PATH regexp pattern and converts it to an accpetable birdc format""" + _open = r"[= " + _close = r" =]" + # Strip out regex characters + stripped = re.sub(r"[\^\$\(\)\.\+]", "", self.target) + # Replace regex _ with wildcard * + replaced = re.sub(r"\_", r"*", stripped) + # Remove extra * as they are not needed with wildcards + replaced_dedup = re.sub(r"\*+", r"*", replaced) + # Pad ASNs & wildcard operators with whitespaces + for sub in ((r"(\d+)", r" \1 "), (r"(\*)", r" \1 ")): + subbed = re.sub(*sub, replaced_dedup) + # Construct bgp_path pattern for birdc + pattern = f"{_open}{subbed}{_close}" + # Remove extra whitespaces from constructed pattern + pattern_dedup = re.sub(r"\s+", " ", pattern) + return pattern_dedup + + def bgp_community(self): + """Takes traditional BGP Community format and converts it to an acceptable birdc format""" + # Replace : with , + subbed = re.sub(r"\:", r",", self.target) + # Wrap in parentheses + pattern = f"({subbed})" + return pattern + + +class Command: + """Imports & exports configured command syntax from configuration file""" + + def __init__(self, query): + self.query_type = query.get("query_type") + self.afi = query.get("afi") + self.source = query.get("source") + self.target = query.get("target", 0) + logger.debug( + f"""Command class initialized with paramaters:\nQuery Type: {self.query_type}\nAFI: \ + {self.afi}\nSource: {self.source}\nTarget: {self.target}""" + ) + + def is_split(self): + """Returns bash command as a list of arguments""" + command_string = ( + conf["commands"][self.afi] + .get(self.query_type) + .format(source=self.source, target=self.target) + ) + command_split = command_string.split(" ") + logger.debug(f"Constructed bash command as list: {command_split}") + return command_split + + def birdc_1(self): + """Returns bash command as a list of arguments, with the birdc commands as separate list \ + elements""" + birdc4_pre = ["birdc", "-r"] + birdc6_pre = ["birdc6", "-r"] + to_run = None + if self.afi == "dual": + fmt_target = getattr(BirdConvert(self.target), self.query_type)() + cmd4 = birdc4_pre + [ + conf["commands"]["1"].get(self.query_type).format(target=fmt_target) + ] + cmd6 = birdc6_pre + [ + conf["commands"]["1"].get(self.query_type).format(target=fmt_target) + ] + to_run = (cmd4, cmd6) + if self.afi == "ipv4": + to_run = birdc4_pre + [ + conf["commands"]["1"].get(self.query_type).format(target=self.target) + ] + if self.afi == "ipv6": + to_run = birdc6_pre + [ + conf["commands"]["1"].get(self.query_type).format(target=self.target) + ] + logger.debug(f"Constructed Command: {to_run}") + if not to_run: + raise RuntimeError("Error constructing birdc commands") + return to_run diff --git a/hyperglass_bird/configuration.toml.example b/hyperglass_bird/configuration.toml.example new file mode 100644 index 0000000..fc7ff43 --- /dev/null +++ b/hyperglass_bird/configuration.toml.example @@ -0,0 +1,19 @@ +debug = false + +[api] +# listen_addr = "*" +# port = 8080 +key = "1234" + +[commands.1] +bgp_route = "show route all where {target} ~ net" +bgp_community = "show route all where {target} ~ bgp_community" +bgp_aspath = "show route all where bgp_path ~ {target}" + +[commands.ipv4] +ping = "ping -4 -c 5 -I {source} {target}" +traceroute = "traceroute -4 -w 1 -q 1 -s {source} {target}" + +[commands.ipv6] +ping = "ping -6 -c 5 -I {source} {target}" +traceroute = "traceroute -6 -w 1 -q 1 -s {source} {target}" diff --git a/hyperglass_bird/execute.py b/hyperglass_bird/execute.py new file mode 100644 index 0000000..2fa174f --- /dev/null +++ b/hyperglass_bird/execute.py @@ -0,0 +1,67 @@ +""" +Execute the constructed command +""" +# Standard Imports +import logging +import subprocess + +# Module Imports +import logzero +from logzero import logger + +# Project Imports +from hyperglass_bird import configuration + +# Logzero Configuration +if configuration.debug_state(): + logzero.loglevel(logging.DEBUG) +else: + logzero.loglevel(logging.INFO) + +bird_version = configuration.bird_version() +logger.debug(f"BIRD Version: {bird_version}") + + +def parse(raw): + """Parses birdc output to remove first 2 lines stating birdc version & access restricted \ + messages""" + # Convert from byte object to string object + raw_str = str(raw, "utf-8") + logger.debug(f"Pre-parsed output:\n{raw_str}") + # Parse birdc ouput to remove first line containing version + parsed = raw_str.split("\n", 2)[2:] + logger.debug(f"Post-parsed output:\n{parsed}") + return parsed + + +def execute(query): + """Gets constructed command string and runs the command via subprocess""" + logger.debug(f"Received query: {query}") + query_type = query.get("query_type") + output = None + status = 500 + try: + command = configuration.Command(query) + if bird_version < 2: + if query_type in ["bgp_route"]: + to_run = command.birdc_1() + logger.debug(f'Running command "{to_run}"') + status = 200 + output = parse(subprocess.check_output(to_run)) + elif query_type in ["bgp_aspath", "bgp_community"]: + to_run = command.birdc_1() + logger.debug(f'Running command "{to_run}"') + status = 200 + output4 = parse(subprocess.check_output(to_run[0])) + output6 = parse(subprocess.check_output(to_run[1])) + output = output4[0] + "\n" + output6[0] + if query_type in ["ping", "traceroute"]: + logger.debug(f'Running bash command "{command}"') + to_run = command.is_split() + output = subprocess.check_output(to_run) + status = 200 + except (RuntimeError, subprocess.CalledProcessError) as error_exception: + output = f"Error running query for {query}" + status = 500 + logger.error(f"Error running query for {query}. Error:\n{error_exception}") + return (output, status) diff --git a/hyperglass_bird/hyperglass_bird.py b/hyperglass_bird/hyperglass_bird.py new file mode 100755 index 0000000..46d4338 --- /dev/null +++ b/hyperglass_bird/hyperglass_bird.py @@ -0,0 +1,62 @@ +""" +hyperglass-bird API Controller +""" +# Standard Imports +import json +import logging + +# Module Imports +import logzero +from logzero import logger +from waitress import serve +from passlib.hash import pbkdf2_sha256 +from flask import Flask, request, Response + +# Project Imports +from hyperglass_bird import execute +from hyperglass_bird import configuration + +# Logzero Configuration +if configuration.debug_state(): + logzero.loglevel(logging.DEBUG) +else: + logzero.loglevel(logging.INFO) + +# Import API Parameters +api = configuration.api() +logger.debug(f"API parameters: {api}") + +# Flask Configuration +app = Flask(__name__) + + +@app.route("/bird", methods=["POST"]) +def bird(): + """Main Flask route ingests JSON parameters and API key hash from hyperglass and passes it to \ + execute module for execution""" + headers = request.headers + logger.debug(f"Request headers:\n{headers}") + api_key_hash = headers.get("X-Api-Key") + # Verify API key hash against plain text value in configuration.py + if pbkdf2_sha256.verify(api["key"], api_key_hash) is True: + logger.debug("Verified API Key") + + query_json = request.get_json() + logger.debug(f"Raw JSON Query:\n{query_json}") + query = json.loads(query_json) + logger.debug(f"Input query data:\n{query}") + logger.debug("Executing query...") + + bird_response = execute.execute(query) + + logger.debug(f"Raw output:\n{bird_response}") + + return Response(bird_response[0], bird_response[1]) + logger.error(f"Validation of API key failed. Hash:\n{api_key_hash}") + return Response("Error: API Key Invalid", 401) + + +# Simple Waitress WSGI implementation +if __name__ == "__main__": + logger.debug("Starting hyperglass-bird API via Waitress...") + serve(app, host=api["listen_addr"], port=api["port"]) diff --git a/manage.py b/manage.py new file mode 100755 index 0000000..3001581 --- /dev/null +++ b/manage.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 +import click +import random +import string +from logzero import logger +from passlib.hash import pbkdf2_sha256 + + +@click.group() +def main(): + pass + + +@main.command("dev-server", help="Start Flask development server") +@click.option("-h", "--host", type=str, default="0.0.0.0", help="Listening IP") +@click.option("-p", "--port", type=int, default=5000, help="TCP Port") +def dev_server(host, port): + try: + from hyperglass_bird import hyperglass_bird + from hyperglass_bird import configuration + + debug_state = configuration.debug_state() + hyperglass_bird.app.run(host="0.0.0.0", debug=True, port=8080) + logger.error("Started test server.") + except: + logger.error("Failed to start test server.") + raise + + +@main.command("generate-key", help="Generate API key & hash") +@click.option( + "-l", "--length", "string_length", type=int, default=16, show_default=True +) +def generatekey(string_length): + """Generates 16 character API Key for hyperglass-bird API, and a corresponding PBKDF2 SHA256 Hash""" + ld = string.ascii_letters + string.digits + api_key = "".join(random.choice(ld) for i in range(string_length)) + key_hash = pbkdf2_sha256.hash(api_key) + click.secho( + f""" +Your API Key is: {api_key} +Place your API Key in the `configuration.toml` of your API module. For example, in: `hyperglass-bird/hyperglass_bird/configuration.toml` + +Your Key Hash is: {key_hash} +Use this hash as the password for the device using the API module. For example, in: `hyperglass/hyperglass/configuration/devices.toml` +""" + ) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..d71a4c1 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +toml +click +flask +logzero +passlib +waitress