├── requirements.txt ├── static └── img │ ├── 01_netbox_webhook_menu.png │ ├── 03_ipam_webhook_config.png │ └── 02_interface_webhook_config.png ├── webhook_listener ├── .gitignore ├── common_functions.py ├── credentials.py.dist ├── main.py ├── README.md ├── config.py ├── interface_api.py └── ipam_api.py ├── NOTICE.md ├── netbox-sample-data ├── ipam-ipv6-example.json ├── ipam-address-deleted.json ├── ipam-new-address-created.json ├── ipam-removed-from-device.json ├── README.md ├── interface-no_mtu-no_desc-shutdown.json ├── interface-mtu-desc-noshut.json └── interface-wireless-updated.json ├── LICENSE └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | Flask ~= 2.2.2 2 | pynetbox ~= 6.6.2 3 | urllib3 ~= 1.26.12 4 | requests ~= 2.28.1 5 | requests-toolbelt ~= 0.9.1 6 | -------------------------------------------------------------------------------- /static/img/01_netbox_webhook_menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoLearning/netbox-webhook-automation/HEAD/static/img/01_netbox_webhook_menu.png -------------------------------------------------------------------------------- /static/img/03_ipam_webhook_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoLearning/netbox-webhook-automation/HEAD/static/img/03_ipam_webhook_config.png -------------------------------------------------------------------------------- /webhook_listener/.gitignore: -------------------------------------------------------------------------------- 1 | # Ignore the credentials.py file to ensure no actual secrets are stored 2 | # in your git repository 3 | credentials.py 4 | -------------------------------------------------------------------------------- /static/img/02_interface_webhook_config.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CiscoLearning/netbox-webhook-automation/HEAD/static/img/02_interface_webhook_config.png -------------------------------------------------------------------------------- /NOTICE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020, Cisco Systems, Inc. and/or its affiliates 2 | 3 | This project includes software developed at Cisco Systems, Inc. and/or its affiliates. 4 | -------------------------------------------------------------------------------- /webhook_listener/common_functions.py: -------------------------------------------------------------------------------- 1 | """ 2 | Common (generic) functions that can be imported by any script/module. 3 | """ 4 | import re 5 | 6 | 7 | def parse_interface_name(interface_name): 8 | """ 9 | Given an interface name, split the string into the type of interface 10 | and the interface ID. Use for generating RESTCONF URLs requiring an 11 | interface specifier. 12 | 13 | :param interface_name: String - name of the interface to parse 14 | :return: Tuple of (interface type, interface ID) 15 | """ 16 | interface_pattern = r"^(\D+)(\d+.*)$" 17 | interface_regex = re.compile(interface_pattern) 18 | 19 | interface_type, interface_id = interface_regex.match(str(interface_name)).groups() 20 | 21 | return interface_type, interface_id 22 | -------------------------------------------------------------------------------- /webhook_listener/credentials.py.dist: -------------------------------------------------------------------------------- 1 | """ 2 | credentials.py.dist: 3 | Example credentials file. Before running the project, copy this file to 4 | "credentials.py" and edit the values to match your environment: 5 | 6 | cp credentials.py.dist credentials.py 7 | 8 | IMPORTANT! NEVER HARD-CODE CREDENTIALS IN PRODUCTION! Use a secret 9 | management system or another method of supplying credentials. This project 10 | is designed to demonstrate functionality in a simulated environment! 11 | """ 12 | # URL and API token for the NetBox instance 13 | NETBOX_URL = "https://netbox" 14 | NETBOX_TOKEN = "0123456789abcdef0123456789abcdef01234567" 15 | 16 | # Username and password for your test environment's IOSXE device 17 | DEVICE_USERNAME = "developer" 18 | DEVICE_PASSWORD = "1234QWer" 19 | -------------------------------------------------------------------------------- /netbox-sample-data/ipam-ipv6-example.json: -------------------------------------------------------------------------------- 1 | { 2 | 'id': 9, 3 | 'url': '/api/ipam/ip-addresses/9/', 4 | 'display': '2001:db8::1:1/64', 5 | 'family': { 6 | 'value': 6, 7 | 'label': 'IPv6' 8 | }, 9 | 'address': '2001:db8::1:1/64', 10 | 'vrf': None, 11 | 'tenant': None, 12 | 'status': { 13 | 'value': 'active', 14 | 'label': 'Active' 15 | }, 16 | 'role': None, 17 | 'assigned_object_type': 'dcim.interface', 18 | 'assigned_object_id': 162, 19 | 'assigned_object': { 20 | 'id': 162, 21 | 'url': '/api/dcim/interfaces/162/', 22 | 'display': 'GigabitEthernet2', 23 | 'device': { 24 | 'id': 57, 25 | 'url': '/api/dcim/devices/57/', 26 | 'display': 'access-router', 27 | 'name': 'access-router' 28 | }, 29 | 'name': 'GigabitEthernet2', 30 | 'cable': None, 31 | '_occupied': False 32 | }, 33 | 'nat_inside': None, 34 | 'nat_outside': None, 35 | 'dns_name': '', 36 | 'description': 'ffaa', 37 | 'tags': [], 38 | 'custom_fields': {}, 39 | 'created': '2022-09-12T13:56:14.124057Z', 40 | 'last_updated': '2022-09-12T15:24:25.765164Z' 41 | } 42 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Cisco Systems 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /webhook_listener/main.py: -------------------------------------------------------------------------------- 1 | """ 2 | Main webhook listener entrypoint. Initialize the Flask app which will listen 3 | for incoming Netbox webhooks and add URL rules to process webhook data sent 4 | to specific URL endpoints 5 | """ 6 | # Import Flask to act as the main webhook listener 7 | from flask import Flask 8 | 9 | # Include the conditional "urllib3" TLS warning disable function 10 | from config import conditionally_disable_tls_warnings 11 | 12 | # There will be two API endpoints created for interfaces and IPAM. Import 13 | # the needed functions from separate Python files which will handle request 14 | # data: 15 | from interface_api import manage_device_interface 16 | from ipam_api import manage_interface_ip_address 17 | 18 | # Disable "insecure HTTPS" messages if no validation is to be performed 19 | conditionally_disable_tls_warnings() 20 | 21 | # Create the initial Flask app 22 | app = Flask(__name__) 23 | 24 | # URLs to be included. "add_url_rule" is a Flask method to specify the target 25 | # API endpoint, the allowed methods, and which function will process incoming 26 | # data. 27 | app.add_url_rule("/api/update-interface", 28 | methods=["POST"], 29 | view_func=manage_device_interface) 30 | 31 | app.add_url_rule("/api/update-address", 32 | methods=["POST"], 33 | view_func=manage_interface_ip_address) 34 | 35 | if __name__ == "__main__": 36 | # If this script is called from the command line, instruct Flask to enable 37 | # debugging for the app and listen on every IP address on the specified 38 | # port. 39 | app.debug = True 40 | app.run(host="0.0.0.0", port=19703) 41 | -------------------------------------------------------------------------------- /netbox-sample-data/ipam-address-deleted.json: -------------------------------------------------------------------------------- 1 | { 2 | 'event': 'deleted', 3 | 'timestamp': '2022-09-12 18:13:04.697977+00:00', 4 | 'model': 'ipaddress', 5 | 'username': 'admin', 6 | 'request_id': 'cd6e4745-3cde-4f0c-86be-2d6550d8ac52', 7 | 'data': { 8 | 'id': 10, 9 | 'url': '/api/ipam/ip-addresses/10/', 10 | 'display': '192.168.222.21/24', 11 | 'family': { 12 | 'value': 4, 13 | 'label': 'IPv4' 14 | }, 15 | 'address': '192.168.222.21/24', 16 | 'vrf': None, 17 | 'tenant': None, 18 | 'status': { 19 | 'value': 'active', 20 | 'label': 'Active' 21 | }, 22 | 'role': None, 23 | 'assigned_object_type': 'dcim.interface', 24 | 'assigned_object_id': 162, 25 | 'assigned_object': { 26 | 'id': 162, 27 | 'url': '/api/dcim/interfaces/162/', 28 | 'display': 'GigabitEthernet2', 29 | 'device': { 30 | 'id': 57, 31 | 'url': '/api/dcim/devices/57/', 32 | 'display': 'access-router', 33 | 'name': 'access-router' 34 | }, 35 | 'name': 'GigabitEthernet2', 36 | 'cable': None, 37 | '_occupied': False 38 | }, 39 | 'nat_inside': None, 40 | 'nat_outside': None, 41 | 'dns_name': 'a', 42 | 'description': '', 43 | 'tags': [], 44 | 'custom_fields': {}, 45 | 'created': '2022-09-12T18:12:33.476288Z', 46 | 'last_updated': '2022-09-12T18:12:51.761132Z' 47 | }, 48 | 'snapshots': { 49 | 'prechange': { 50 | 'created': '2022-09-12T18:12:33.476Z', 51 | 'last_updated': '2022-09-12T18:12:51.761Z', 52 | 'address': '192.168.222.21/24', 53 | 'vrf': None, 54 | 'tenant': None, 55 | 'status': 'active', 56 | 'role': '', 57 | 'assigned_object_type': 32, 58 | 'assigned_object_id': 162, 59 | 'nat_inside': None, 60 | 'dns_name': 'a', 61 | 'description': '', 62 | 'custom_fields': {}, 63 | 'tags': [] 64 | }, 65 | 'postchange': None 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /netbox-sample-data/ipam-new-address-created.json: -------------------------------------------------------------------------------- 1 | { 2 | 'event': 'created', 3 | 'timestamp': '2022-09-12 18:14:18.353747+00:00', 4 | 'model': 'ipaddress', 5 | 'username': 'admin', 6 | 'request_id': 'bc8bf1d7-74fe-4b9d-be04-6b0002500883', 7 | 'data': { 8 | 'id': 11, 9 | 'url': '/api/ipam/ip-addresses/11/', 10 | 'display': '192.168.222.22/24', 11 | 'family': { 12 | 'value': 4, 13 | 'label': 'IPv4' 14 | }, 15 | 'address': '192.168.222.22/24', 16 | 'vrf': None, 17 | 'tenant': None, 18 | 'status': { 19 | 'value': 'active', 20 | 'label': 'Active' 21 | }, 22 | 'role': None, 23 | 'assigned_object_type': 'dcim.interface', 24 | 'assigned_object_id': 162, 25 | 'assigned_object': { 26 | 'id': 162, 27 | 'url': '/api/dcim/interfaces/162/', 28 | 'display': 'GigabitEthernet2', 29 | 'device': { 30 | 'id': 57, 31 | 'url': '/api/dcim/devices/57/', 32 | 'display': 'access-router', 33 | 'name': 'access-router' 34 | }, 35 | 'name': 'GigabitEthernet2', 36 | 'cable': None, 37 | '_occupied': False 38 | }, 39 | 'nat_inside': None, 40 | 'nat_outside': None, 41 | 'dns_name': '', 42 | 'description': '', 43 | 'tags': [], 44 | 'custom_fields': {}, 45 | 'created': '2022-09-12T18:14:18.326253Z', 46 | 'last_updated': '2022-09-12T18:14:18.326268Z' 47 | }, 48 | 'snapshots': { 49 | 'prechange': None, 50 | 'postchange': { 51 | 'created': '2022-09-12T18:14:18.326Z', 52 | 'last_updated': '2022-09-12T18:14:18.326Z', 53 | 'address': '192.168.222.22/24', 54 | 'vrf': None, 55 | 'tenant': None, 56 | 'status': 'active', 57 | 'role': '', 58 | 'assigned_object_type': 32, 59 | 'assigned_object_id': 162, 60 | 'nat_inside': None, 61 | 'dns_name': '', 62 | 'description': '', 63 | 'custom_fields': {}, 64 | 'tags': [] 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /netbox-sample-data/ipam-removed-from-device.json: -------------------------------------------------------------------------------- 1 | { 2 | 'event': 'updated', 3 | 'timestamp': '2022-09-12 20:56:17.833015+00:00', 4 | 'model': 'ipaddress', 5 | 'username': 'admin', 6 | 'request_id': '9140766a-8a53-495b-9c1c-ada2a5e0725e', 7 | 'data': { 8 | 'id': 13, 9 | 'url': '/api/ipam/ip-addresses/13/', 10 | 'display': '192.168.222.23/24', 11 | 'family': { 12 | 'value': 4, 13 | 'label': 'IPv4' 14 | }, 15 | 'address': '192.168.222.23/24', 16 | 'vrf': None, 17 | 'tenant': None, 18 | 'status': { 19 | 'value': 'active', 20 | 'label': 'Active' 21 | }, 22 | 'role': None, 23 | 'assigned_object_type': None, 24 | 'assigned_object_id': None, 25 | 'assigned_object': None, 26 | 'nat_inside': None, 27 | 'nat_outside': None, 28 | 'dns_name': '', 29 | 'description': '', 30 | 'tags': [], 31 | 'custom_fields': {}, 32 | 'created': '2022-09-12T20:56:07.668753Z', 33 | 'last_updated': '2022-09-12T20:56:17.813177Z' 34 | }, 35 | 'snapshots': { 36 | 'prechange': { 37 | 'created': '2022-09-12T20:56:07.668Z', 38 | 'last_updated': '2022-09-12T20:56:07.668Z', 39 | 'address': '192.168.222.23/24', 40 | 'vrf': None, 41 | 'tenant': None, 42 | 'status': 'active', 43 | 'role': '', 44 | 'assigned_object_type': 32, 45 | 'assigned_object_id': 162, 46 | 'nat_inside': None, 47 | 'dns_name': '', 48 | 'description': '', 49 | 'custom_fields': {}, 50 | 'tags': [] 51 | }, 52 | 'postchange': { 53 | 'created': '2022-09-12T20:56:07.668Z', 54 | 'last_updated': '2022-09-12T20:56:17.813Z', 55 | 'address': '192.168.222.23/24', 56 | 'vrf': None, 57 | 'tenant': None, 58 | 'status': 'active', 59 | 'role': '', 60 | 'assigned_object_type': None, 61 | 'assigned_object_id': None, 62 | 'nat_inside': None, 63 | 'dns_name': '', 64 | 'description': '', 65 | 'custom_fields': {}, 66 | 'tags': [] 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /netbox-sample-data/README.md: -------------------------------------------------------------------------------- 1 | # NetBox Sample Webhook Data 2 | Files in this directory contain actual JSON payloads generated by NetBox webhooks in different scenarios. Use these as a guide to identify interesting data fields to create your own webhook listeners! 3 | 4 | Instead of launching the ```main.py``` Flask application, these payloads were captured using NetCat with the commandline: 5 | 6 |
 7 | (venv) $ nc -l 19703
 8 | 
9 | 10 | Replace ```19703``` with the port your NetBox webhook is configured to send. Modify an interface setting or IP address, and collect the output. As you progress, you will have a sample library of JSON payloads to help guide your own development efforts. 11 | 12 | ## interface-mtu-desc-noshut.json 13 | **Interface** webhook. An MTU of 9000 was set, the interface was provided a description in NetBox, and the **enabled** checkbox was selected. 14 | 15 | ## interface-no_mtu-no_desc-shutdown.json 16 | **Interface** webhook. The MTU field was deleted / set to nothing, the description field was cleared, and the **enabled** checkbox was UNchecked. 17 | 18 | ## interface-wireless-updated.json 19 | **Interface** webhook. This is the data from a *wireless* interface that was updated. Note some of the extra fields indicating wireless-specific settings. Managing a wireless access point? Start here for the data you might encounter! 20 | 21 | ## ipam-address-deleted.json 22 | **IP Address** webhook. An IP address was deleted from NetBox, so should be UNconfigured from a device interface. Can you find the relevant information? 23 | 24 | ## ipam-ipv6-example.json 25 | **IP Address** webhook. Here, an IPv6 address was configured on an interface. How would you gather the device, interface, and IPv6 address information? 26 | 27 | ## ipam-new-address-created.json 28 | **IP Address** webhook. A new address has been created and was assigned to an interface. 29 | 30 | ## ipam-removed-from-device.json 31 | **IP Address** webhook. The IP address was not deleted, but is no longer associated with any interface. This should be processed to remove the old IP address! -------------------------------------------------------------------------------- /webhook_listener/README.md: -------------------------------------------------------------------------------- 1 | # NetBox Webhook Listener: Application Files 2 | 3 | This application is *not* designed to demonstrate best practices for Python Flask apps, but to demonstrate that even a simple application can result in useful automation tasks. 4 | 5 | Below is a listing of the source files and a description of what they contain and the tasks performed. 6 | 7 | ## .gitignore 8 | When present, the ```.gitignore``` file is processed by ```git``` and provides instruction on files to exclude from the repository. In this project, the ```.gitignore``` file has a single entry: ```credentials.py```. This allows a templated credential file (with the extension ```.dist```) to be present in the repository, but the copied ```credentials.py``` file will be excluded from any ```git add``` or ```git commit``` operation. More information on ```.gitignore``` can be found at [git scm documentation for gitignore](https://git-scm.com/docs/gitignore). 9 | 10 | ## credentials.py(.dist) 11 | This file contains variable definitions for the NetBox URL and API token as well as device credentials for the simulated environment. ```credentials.py.dist``` contains generic examples, and should be copied to a file named ```credentials.py``` which will be read by the main application. The ```credentials.py``` file will never be included in the ```git``` repository due to the ```.gitignore``` contents. 12 | 13 | ## config.py 14 | Contains configuration settings for the webhook listener. TLS verification settings, HTTP retry settings, and HTTP headers common to the RESTCONF JSON implementation are defined in this file. Some functions are present to prepare the pynetbox API as well as a ```requests.Session()``` object, which will be used when performing RESTCONF operations against the simulated network devices. 15 | 16 | ## common_functions.py 17 | Functions that may be imported and used by any script are in this file. To import and use functions, you can use an ```import``` statement at the top of your Python script like so: 18 | 19 | ```python 20 | from common_functions import parse_interface_name 21 | ``` 22 | 23 | After this import statement, the common ```parse_interface_name``` function can be accessed. 24 | 25 | ## main.py 26 | The initial entrypoint of the Flask application. The ```main.py``` file contains initialization functions to start Flask and set URL endpoints for anticipated incoming webhooks. 27 | 28 | ## interface_api.py 29 | Webhooks related to interface operations will use functions contained in this file. There is a function which handles incoming data, named ```manage_device_interface```. Data is parsed by this function and supporting functions are called as necessary to configure an interface on the target device. 30 | 31 | ## ipam_api.py 32 | Webhooks related to IPAM operations will use these functions. The ```manage_interface_ip_address``` function is imported by the Flask app in ```main.py``` and provides data parsing and appropriate routing to configure (or unconfigure) interface IP addresses based on the data received by NetBox. 33 | -------------------------------------------------------------------------------- /webhook_listener/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Docstring. 3 | """ 4 | import requests 5 | from requests.adapters import HTTPAdapter 6 | from urllib3 import disable_warnings, Retry 7 | import pynetbox 8 | from credentials import (NETBOX_URL, 9 | NETBOX_TOKEN, 10 | DEVICE_USERNAME, 11 | DEVICE_PASSWORD) 12 | 13 | # Boolean - perform TLS validation when consuming remote APIs? For production 14 | # use, this should be set to True! 15 | TLS_VERIFY = False 16 | 17 | # Configure a retry/backoff strategy for RESTCONF calls. Sometimes the NETCONF/ 18 | # RESTCONF datastore is locked while configuration synchronization is being 19 | # performed, which will result in an HTTP 409 status code being returned. 20 | # Instead of timing out and failing, the Retry configuration will continue 21 | # attempting the API request for (total) retries with an incremental backoff 22 | # specified by (backoff_factor). 23 | # The (status_forcelist) is a list containing status codes which should be 24 | # retried, and (method_whitelist) instructs Python to perform retries for 25 | # the specified HTTP verbs. 26 | RESTCONF_RETRY_CONFIG = Retry( 27 | total=8, 28 | status_forcelist=[409], 29 | method_whitelist=["PATCH", "POST", "PUT"], 30 | backoff_factor=1 31 | ) 32 | 33 | # When creating an HTTP session, these are the headers that will be included 34 | # for every request. Code duplication is reduced as these headers will be 35 | # attached to a requests.Session() object, meaning that scripts do not need 36 | # to define headers for every request. 37 | RESTCONF_HEADERS = { 38 | "Content-Type": "application/yang-data+json", 39 | "Accept": "application/yang-data+json" 40 | } 41 | 42 | 43 | def conditionally_disable_tls_warnings(): 44 | """ 45 | If TLS chain validation is disabled, prevent the urllib3 package from 46 | displaying "Unsafe operation" type of messages. 47 | 48 | The calling script (__main__) should invoke this function only one time. 49 | 50 | :return: None 51 | """ 52 | if not TLS_VERIFY: 53 | disable_warnings() 54 | 55 | 56 | def create_restconf_session(): 57 | """ 58 | Initialize a Python requests.Session() object with "extra" options such 59 | as HTTP retry / incremental backoff. The Session() object also will 60 | set default values for authentication and header values such as 61 | Content-Type or Accept. 62 | 63 | Once the session has been created, perform any normal urllib3/requests 64 | verbs against the session to take advantage of the configured settings. 65 | 66 | :return: requests.Session() object 67 | """ 68 | http_adapter = HTTPAdapter(max_retries=RESTCONF_RETRY_CONFIG) 69 | http_session = requests.Session() 70 | http_session.mount("https://", http_adapter) 71 | http_session.mount("http://", http_adapter) 72 | http_session.auth = (DEVICE_USERNAME, DEVICE_PASSWORD) 73 | http_session.headers = RESTCONF_HEADERS 74 | http_session.verify = TLS_VERIFY 75 | 76 | return http_session 77 | 78 | 79 | def create_netbox_api(): 80 | """ 81 | Initialize a pynetbox API object and attach an HTTP session object to 82 | specify whether TLS validation should be performed. 83 | 84 | :return: pynetbox API object 85 | """ 86 | # Initialize the pynetbox API object 87 | netbox = pynetbox.api(url=NETBOX_URL, token=NETBOX_TOKEN) 88 | 89 | # pynetbox does not support TLS validation enable/disable functionality 90 | # but does accept an optional Session object. Use this to specify 91 | # the TLS validation option and attach the Session object to the 92 | # pynetbox instance. 93 | api_session = requests.Session() 94 | api_session.verify = TLS_VERIFY 95 | netbox.http_session = api_session 96 | 97 | return netbox 98 | 99 | # restconf_session and/or netbox_api should be imported by scripts requiring 100 | # access to RESTCONF sessions or the Netbox API. 101 | restconf_session = create_restconf_session() 102 | netbox_api = create_netbox_api() 103 | -------------------------------------------------------------------------------- /netbox-sample-data/interface-no_mtu-no_desc-shutdown.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "updated", 3 | "timestamp": "2022-09-19 21:57:43.198860+00:00", 4 | "model": "interface", 5 | "username": "admin", 6 | "request_id": "515aa19b-1083-4b27-9af4-d212d3b8e573", 7 | "data": { 8 | "id": 171, 9 | "url": "/api/dcim/interfaces/171/", 10 | "display": "GigabitEthernet3", 11 | "device": { 12 | "id": 58, 13 | "url": "/api/dcim/devices/58/", 14 | "display": "access-rtr02", 15 | "name": "access-rtr02" 16 | }, 17 | "module": null, 18 | "name": "GigabitEthernet3", 19 | "label": "", 20 | "type": { 21 | "value": "10gbase-t", 22 | "label": "10GBASE-T (10GE)" 23 | }, 24 | "enabled": false, 25 | "parent": null, 26 | "bridge": null, 27 | "lag": null, 28 | "mtu": null, 29 | "mac_address": null, 30 | "speed": null, 31 | "duplex": null, 32 | "wwn": "", 33 | "mgmt_only": false, 34 | "description": "", 35 | "mode": null, 36 | "rf_role": null, 37 | "rf_channel": null, 38 | "rf_channel_frequency": null, 39 | "rf_channel_width": null, 40 | "tx_power": null, 41 | "untagged_vlan": null, 42 | "tagged_vlans": [], 43 | "mark_connected": false, 44 | "cable": null, 45 | "wireless_link": null, 46 | "link_peer": null, 47 | "link_peer_type": null, 48 | "wireless_lans": [], 49 | "vrf": null, 50 | "connected_endpoint": null, 51 | "connected_endpoint_type": null, 52 | "connected_endpoint_reachable": null, 53 | "tags": [], 54 | "custom_fields": { 55 | "wlc_rf_channel": null 56 | }, 57 | "created": "2022-09-13T15:58:25.987901Z", 58 | "last_updated": "2022-09-19T21:57:43.156147Z", 59 | "count_ipaddresses": 1, 60 | "count_fhrp_groups": 1, 61 | "_occupied": false 62 | }, 63 | "snapshots": { 64 | "prechange": { 65 | "created": "2022-09-13T15:58:25.987Z", 66 | "last_updated": "2022-09-19T21:56:54.739Z", 67 | "device": 58, 68 | "name": "GigabitEthernet3", 69 | "label": "", 70 | "description": "Interface description text!", 71 | "module": null, 72 | "cable": null, 73 | "mark_connected": false, 74 | "enabled": true, 75 | "mac_address": null, 76 | "mtu": 9000, 77 | "mode": "", 78 | "parent": null, 79 | "bridge": null, 80 | "lag": null, 81 | "type": "10gbase-t", 82 | "mgmt_only": false, 83 | "speed": null, 84 | "duplex": null, 85 | "wwn": null, 86 | "rf_role": "", 87 | "rf_channel": "", 88 | "rf_channel_frequency": null, 89 | "rf_channel_width": null, 90 | "tx_power": null, 91 | "wireless_link": null, 92 | "untagged_vlan": null, 93 | "vrf": null, 94 | "wireless_lans": [], 95 | "tagged_vlans": [], 96 | "custom_fields": { 97 | "wlc_rf_channel": null 98 | }, 99 | "tags": [] 100 | }, 101 | "postchange": { 102 | "created": "2022-09-13T15:58:25.987Z", 103 | "last_updated": "2022-09-19T21:57:43.156Z", 104 | "device": 58, 105 | "name": "GigabitEthernet3", 106 | "label": "", 107 | "description": "", 108 | "module": null, 109 | "cable": null, 110 | "mark_connected": false, 111 | "enabled": false, 112 | "mac_address": null, 113 | "mtu": null, 114 | "mode": "", 115 | "parent": null, 116 | "bridge": null, 117 | "lag": null, 118 | "type": "10gbase-t", 119 | "mgmt_only": false, 120 | "speed": null, 121 | "duplex": null, 122 | "wwn": "", 123 | "rf_role": "", 124 | "rf_channel": "", 125 | "rf_channel_frequency": null, 126 | "rf_channel_width": null, 127 | "tx_power": null, 128 | "wireless_link": null, 129 | "untagged_vlan": null, 130 | "vrf": null, 131 | "wireless_lans": [], 132 | "tagged_vlans": [], 133 | "custom_fields": { 134 | "wlc_rf_channel": null 135 | }, 136 | "tags": [] 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /netbox-sample-data/interface-mtu-desc-noshut.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "updated", 3 | "timestamp": "2022-09-19 21:56:54.781834+00:00", 4 | "model": "interface", 5 | "username": "admin", 6 | "request_id": "6735e45f-f412-4e31-a845-e585c767a6e2", 7 | "data": { 8 | "id": 171, 9 | "url": "/api/dcim/interfaces/171/", 10 | "display": "GigabitEthernet3", 11 | "device": { 12 | "id": 58, 13 | "url": "/api/dcim/devices/58/", 14 | "display": "access-rtr02", 15 | "name": "access-rtr02" 16 | }, 17 | "module": null, 18 | "name": "GigabitEthernet3", 19 | "label": "", 20 | "type": { 21 | "value": "10gbase-t", 22 | "label": "10GBASE-T (10GE)" 23 | }, 24 | "enabled": true, 25 | "parent": null, 26 | "bridge": null, 27 | "lag": null, 28 | "mtu": 9000, 29 | "mac_address": null, 30 | "speed": null, 31 | "duplex": null, 32 | "wwn": "", 33 | "mgmt_only": false, 34 | "description": "Interface description text!", 35 | "mode": null, 36 | "rf_role": null, 37 | "rf_channel": null, 38 | "rf_channel_frequency": null, 39 | "rf_channel_width": null, 40 | "tx_power": null, 41 | "untagged_vlan": null, 42 | "tagged_vlans": [], 43 | "mark_connected": false, 44 | "cable": null, 45 | "wireless_link": null, 46 | "link_peer": null, 47 | "link_peer_type": null, 48 | "wireless_lans": [], 49 | "vrf": null, 50 | "connected_endpoint": null, 51 | "connected_endpoint_type": null, 52 | "connected_endpoint_reachable": null, 53 | "tags": [], 54 | "custom_fields": { 55 | "wlc_rf_channel": null 56 | }, 57 | "created": "2022-09-13T15:58:25.987901Z", 58 | "last_updated": "2022-09-19T21:56:54.739666Z", 59 | "count_ipaddresses": 1, 60 | "count_fhrp_groups": 1, 61 | "_occupied": false 62 | }, 63 | "snapshots": { 64 | "prechange": { 65 | "created": "2022-09-13T15:58:25.987Z", 66 | "last_updated": "2022-09-14T13:57:23.757Z", 67 | "device": 58, 68 | "name": "GigabitEthernet3", 69 | "label": "", 70 | "description": "", 71 | "module": null, 72 | "cable": null, 73 | "mark_connected": false, 74 | "enabled": false, 75 | "mac_address": null, 76 | "mtu": null, 77 | "mode": "", 78 | "parent": null, 79 | "bridge": null, 80 | "lag": null, 81 | "type": "10gbase-t", 82 | "mgmt_only": false, 83 | "speed": null, 84 | "duplex": null, 85 | "wwn": null, 86 | "rf_role": "", 87 | "rf_channel": "", 88 | "rf_channel_frequency": null, 89 | "rf_channel_width": null, 90 | "tx_power": null, 91 | "wireless_link": null, 92 | "untagged_vlan": null, 93 | "vrf": null, 94 | "wireless_lans": [], 95 | "tagged_vlans": [], 96 | "custom_fields": { 97 | "wlc_rf_channel": null 98 | }, 99 | "tags": [] 100 | }, 101 | "postchange": { 102 | "created": "2022-09-13T15:58:25.987Z", 103 | "last_updated": "2022-09-19T21:56:54.739Z", 104 | "device": 58, 105 | "name": "GigabitEthernet3", 106 | "label": "", 107 | "description": "Interface description text!", 108 | "module": null, 109 | "cable": null, 110 | "mark_connected": false, 111 | "enabled": true, 112 | "mac_address": null, 113 | "mtu": 9000, 114 | "mode": "", 115 | "parent": null, 116 | "bridge": null, 117 | "lag": null, 118 | "type": "10gbase-t", 119 | "mgmt_only": false, 120 | "speed": null, 121 | "duplex": null, 122 | "wwn": "", 123 | "rf_role": "", 124 | "rf_channel": "", 125 | "rf_channel_frequency": null, 126 | "rf_channel_width": null, 127 | "tx_power": null, 128 | "wireless_link": null, 129 | "untagged_vlan": null, 130 | "vrf": null, 131 | "wireless_lans": [], 132 | "tagged_vlans": [], 133 | "custom_fields": { 134 | "wlc_rf_channel": null 135 | }, 136 | "tags": [] 137 | } 138 | } 139 | } -------------------------------------------------------------------------------- /netbox-sample-data/interface-wireless-updated.json: -------------------------------------------------------------------------------- 1 | { 2 | "event": "updated", 3 | "timestamp": "2022-09-09 17:11:46.389375+00:00", 4 | "model": "interface", 5 | "username": "admin", 6 | "request_id": "154c1cea-7db5-4605-a69a-f61f5dbb6994", 7 | "data": { 8 | "id": 124, 9 | "url": "/api/dcim/interfaces/124/", 10 | "display": "radio0 (AP-Label-here)", 11 | "device": { 12 | "id": 42, 13 | "url": "/api/dcim/devices/42/", 14 | "display": "APMCO11N32 (31907)", 15 | "name": "APMCO11N32" 16 | }, 17 | "module": null, 18 | "name": "radio0", 19 | "label": "AP-Label-here", 20 | "type": { 21 | "value": "ieee802.11n", 22 | "label": "IEEE 802.11n" 23 | }, 24 | "enabled": true, 25 | "parent": null, 26 | "bridge": null, 27 | "lag": null, 28 | "mtu": 1500, 29 | "mac_address": "12:34:AB:CD:10:AA", 30 | "speed": 54, 31 | "duplex": { 32 | "value": "full", 33 | "label": "Full" 34 | }, 35 | "wwn": "10:00:00:90:FA:25:37:D6", 36 | "mgmt_only": false, 37 | "description": "radio0 interface", 38 | "mode": { 39 | "value": "tagged-all", 40 | "label": "Tagged (All)" 41 | }, 42 | "rf_role": { 43 | "value": "ap", 44 | "label": "Access point" 45 | }, 46 | "rf_channel": { 47 | "value": "2.4g-11-2462-22", 48 | "label": "11 (2462 MHz)" 49 | }, 50 | "rf_channel_frequency": 2462.0, 51 | "rf_channel_width": 22.0, 52 | "tx_power": 12, 53 | "untagged_vlan": null, 54 | "tagged_vlans": [], 55 | "mark_connected": false, 56 | "cable": null, 57 | "wireless_link": null, 58 | "link_peer": null, 59 | "link_peer_type": null, 60 | "wireless_lans": [], 61 | "vrf": null, 62 | "connected_endpoint": null, 63 | "connected_endpoint_type": null, 64 | "connected_endpoint_reachable": null, 65 | "tags": [ 66 | { 67 | "id": 1, 68 | "url": "/api/extras/tags/1/", 69 | "display": "test-tag-1", 70 | "name": "test-tag-1", 71 | "slug": "test-tag-1", 72 | "color": "9e9e9e" 73 | } 74 | ], 75 | "custom_fields": { 76 | "wlc_rf_channel": "11" 77 | }, 78 | "created": "2022-08-10T18:33:59.103325Z", 79 | "last_updated": "2022-09-09T17:11:46.295105Z", 80 | "count_ipaddresses": 0, 81 | "count_fhrp_groups": 0, 82 | "_occupied": false 83 | }, 84 | "snapshots": { 85 | "prechange": { 86 | "created": "2022-08-10T18:33:59.103Z", 87 | "last_updated": "2022-08-27T23:49:37.374Z", 88 | "device": 42, 89 | "name": "radio0", 90 | "label": "", 91 | "description": "radio0 interface", 92 | "module": null, 93 | "cable": null, 94 | "mark_connected": false, 95 | "enabled": true, 96 | "mac_address": "12:34:AB:CD:10:AA", 97 | "mtu": null, 98 | "mode": "", 99 | "parent": null, 100 | "bridge": null, 101 | "lag": null, 102 | "type": "ieee802.11n", 103 | "mgmt_only": false, 104 | "speed": null, 105 | "duplex": null, 106 | "wwn": null, 107 | "rf_role": "ap", 108 | "rf_channel": "2.4g-11-2462-22", 109 | "rf_channel_frequency": null, 110 | "rf_channel_width": "22.000", 111 | "tx_power": 12, 112 | "wireless_link": null, 113 | "untagged_vlan": null, 114 | "vrf": null, 115 | "wireless_lans": [], 116 | "tagged_vlans": [], 117 | "custom_fields": { 118 | "wlc_rf_channel": "11" 119 | }, 120 | "tags": [] 121 | }, 122 | "postchange": { 123 | "created": "2022-08-10T18:33:59.103Z", 124 | "last_updated": "2022-09-09T17:11:46.295Z", 125 | "device": 42, 126 | "name": "radio0", 127 | "label": "AP-Label-here", 128 | "description": "radio0 interface", 129 | "module": null, 130 | "cable": null, 131 | "mark_connected": false, 132 | "enabled": true, 133 | "mac_address": "12:34:AB:CD:10:AA", 134 | "mtu": 1500, 135 | "mode": "tagged-all", 136 | "parent": null, 137 | "bridge": null, 138 | "lag": null, 139 | "type": "ieee802.11n", 140 | "mgmt_only": false, 141 | "speed": 54, 142 | "duplex": "full", 143 | "wwn": "10:00:00:90:FA:25:37:D6", 144 | "rf_role": "ap", 145 | "rf_channel": "2.4g-11-2462-22", 146 | "rf_channel_frequency": "2462.00", 147 | "rf_channel_width": "22.000", 148 | "tx_power": 12, 149 | "wireless_link": null, 150 | "untagged_vlan": null, 151 | "vrf": null, 152 | "wireless_lans": [], 153 | "tagged_vlans": [], 154 | "custom_fields": { 155 | "wlc_rf_channel": "11" 156 | }, 157 | "tags": [ 158 | "test-tag-1" 159 | ] 160 | } 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /webhook_listener/interface_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions used when a Webhook request is received for Netbox interface objects 3 | """ 4 | # ipaddress is needed to parse incoming IP data 5 | import ipaddress 6 | 7 | # "g" represents a Flask app global variable which can be used throughout the 8 | # applicaiton. 9 | # "request" represents the Flask received data 10 | # "Response" is an object that we can use to return an HTTP status code to the 11 | # NetBox calling webhook. 12 | from flask import g, request, Response 13 | 14 | # Import the configured NetBox API object and the RESTCONF session object from 15 | # config.py 16 | from config import netbox_api, restconf_session 17 | 18 | # ... And, import the function to parse an interface name into a type and ID 19 | from common_functions import parse_interface_name 20 | 21 | 22 | def set_interface_status(netbox_interface_object): 23 | """ 24 | Given a pynetbox interface object, determine if this interface should be 25 | shutdown or not. Generate the appropriate RESTCONF payload and send as 26 | a patch. 27 | 28 | Per the YANG model, enabling an interface should require a DELETE request 29 | to the interface reference. Disabling (shutdown) an interface requires 30 | a "shutdown" payload as a list with no data. 31 | 32 | If an interface is already enabled and the DELETE payload is sent, a 33 | 404 will be returned, indicating that there is nothing to delete. 34 | 35 | :param netbox_interface_object: pynetbox interface object reference 36 | :return: None 37 | """ 38 | interface_status = netbox_interface_object.enabled 39 | 40 | url = f"{g.baseurl}/shutdown" 41 | 42 | if interface_status: 43 | url = f"{url}" 44 | print(f"\tEnabling interface.\n\tTarget URL: {url}") 45 | response = restconf_session.delete(url=url) 46 | if response.status_code == 404: 47 | print(f"\tInterface {netbox_interface_object.name} is already enabled!") 48 | else: 49 | payload = { 50 | "shutdown": [ 51 | None 52 | ] 53 | } 54 | print(f"\tDISabling interface.\n\tTarget URL: {url}\n\tPayload:\n\t{payload}") 55 | response = restconf_session.put(url=url, json=payload) 56 | print(f"\tResponse: {response.status_code} ({response.reason})\n") 57 | 58 | 59 | def update_interface_description(netbox_interface_object): 60 | """ 61 | Change or remove the interface description, depending on what the desired 62 | state is from NetBox. 63 | 64 | If an interface description is defined, use an HTTP PUT to create or 65 | replace the description with what NetBox says it should be. 66 | 67 | If the interface description was removed (set to a null string), send 68 | an HTTP DELETE command to remove. 69 | 70 | :param netbox_interface_object: pynetbox interface object reference 71 | :return: None 72 | """ 73 | url = f"{g.baseurl}/description" 74 | 75 | if interface_description := netbox_interface_object.description: 76 | payload = { 77 | "description": interface_description 78 | } 79 | print(f"\tSetting interface description to '{interface_description}'\n" 80 | f"\tURL: {url}\n\tPayload:\n\t{payload}") 81 | response = restconf_session.put(url=url, json=payload) 82 | else: 83 | print(f"\tRemoving interface description.\n\tURL: {url}") 84 | response = restconf_session.delete(url=url) 85 | print(f"\tResponse: {response.status_code} ({response.reason})\n") 86 | 87 | 88 | def update_interface_mtu(netbox_interface_object): 89 | """ 90 | Set the interface MTU (note: not a TCP MSS or an IP MTU, the actual MTU 91 | allowed by the interface!). 92 | 93 | If an MTU is defined, replace is using an HTTP PUT. If no interface MTU 94 | is specified, set it to a default value - generally 1500 unless it's IPv6 95 | which is not covered here; a 1280-byte payload will be fine with an 96 | interface MTU of 1500 :-) 97 | 98 | :param netbox_interface_object: pynetbox interface object reference 99 | :return: None 100 | """ 101 | url = f"{g.baseurl}/mtu" 102 | 103 | default_mtu = 1500 104 | 105 | if desired_mtu := netbox_interface_object.mtu: 106 | mtu_payload = desired_mtu 107 | else: 108 | mtu_payload = default_mtu 109 | 110 | payload = { 111 | "mtu": mtu_payload 112 | } 113 | print(f"\tSetting interface MTU to '{mtu_payload}'\n\tURL: {url}\n\tPayload:\n\t{payload}") 114 | response = restconf_session.put(url=url, json=payload) 115 | 116 | print(f"\tResponse: {response.status_code} ({response.reason})\n") 117 | 118 | 119 | def manage_device_interface(): 120 | """ 121 | Function for the Webhook listener at the interface configuration path. 122 | 123 | When an interface webhook is received from NetBox, parse the incoming 124 | request and perform the following: 125 | - Set the interface status (shutdown | no shutdown) 126 | - Set the interface description 127 | - Set the interface MTU 128 | 129 | Each task will call a function which performs a RESTCONF operation to 130 | configure the interface on a device. 131 | 132 | :return: A generic HTTP 204 response - the webhook listener doesn't care 133 | if this succeeds or not, it's just sending data! 134 | """ 135 | 136 | # Get all the interface data from NetBox. 137 | interface_data = netbox_api.dcim.interfaces.get(request.json["data"]["id"]) 138 | 139 | # Split out the interface type and ID for RESTCONF operations. The YANG 140 | # model is expecting a list reference to identify the interface for 141 | # configuration in the format =. Call the 142 | # common function to parse the interface into separate type/id for use in 143 | # subsequent tasks. 144 | interface_type, interface_id = parse_interface_name(interface_data.name) 145 | 146 | # Get the "primary_ip" of the device to configure. This is where RESTCONF 147 | # requests will be send. 148 | mgmt_ip = format(ipaddress.IPv4Interface(interface_data.device.primary_ip).ip) 149 | 150 | # The variable "g" is imported by Flask and represents a global variable which 151 | # is accessible by any during the request. Every function in this module 152 | # will use the same base URL, which includes the interface identifier. 153 | g.baseurl = f"https://{mgmt_ip}/restconf/data/Cisco-IOS-XE-native:native/interface/" \ 154 | f"{interface_type}={interface_id}" 155 | 156 | print(f"Configuring interface '{interface_data.name}' on device" 157 | f" '{interface_data.device.name}'...") 158 | 159 | # If this is the management interface, don't change it! Nothing worse than 160 | # killing your session because you moved the management interface in the 161 | # middle of a configuration task :-) 162 | if interface_data.mgmt_only: 163 | print("\tManagement interface, no changes will be performed...") 164 | else: 165 | # Magic happens here - set the status, description, and MTU. 166 | set_interface_status(netbox_interface_object=interface_data) 167 | update_interface_description(netbox_interface_object=interface_data) 168 | update_interface_mtu(netbox_interface_object=interface_data) 169 | 170 | # Return a generic 204 response to the NetBox webhook 171 | return Response(status=204) 172 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # NetBox Webhook Automation 2 | 3 | As demonstrated in an episode of Cisco DevNet Snack Minute on YouTube! 4 | 5 | This is a demonstration of using Python to receive and process webhook data generated by NetBox. Initial use cases: 6 | 7 | - Interfaces: 8 | - Add / change / remove interface description 9 | - Change interface MTU 10 | - Change interface state (shutdown / no shutdown) 11 | - IPAM: 12 | - Add / change / remove IPv4 or IPv6 addresses on interfaces 13 | 14 | ## Requirements: 15 | - Python 3.8 or higher (this code was developed using Python 3.9) 16 | - Physical or virtual Cisco IOS XE device with RESTCONF enabled 17 | - Code tested against a Cisco Catalyst 8000V Edge (virtual) device running IOS XE 17.06.03a 18 | - Running NetBox instance provisioned with an administrative API token 19 | 20 | ## Getting started with this application: 21 | 22 | ### Clone the repository and create a Python virtual environment: 23 | 24 | 1. Use the ```git clone``` command to create a local repository containing the source code: 25 |
 26 | $ git clone https://github.com/CiscoLearning/netbox-webhook-automation.git
 27 | Cloning into 'netbox-webhook-automation'...
 28 | 
29 | 30 | 2. Once the repository has been cloned to the local machine, create a new Python virtual environment using the desired Python interpreter. In this example, Python 3.9 will be used: 31 | 32 |
 33 | $ python3.9 -m venv venv
 34 | $ source venv/bin/activate
 35 | (venv) $
 36 | 
37 | 38 | 3. After the virtual environment has been activated, it's recommended to update the ```pip```, ```setuptools```, and ```wheel``` packages to the latest version. Use the ```pip install --upgrade``` command with the list of packages to upgrade: 39 | 40 |
 41 | (venv) $ pip install --upgrade pip setuptools wheel
 42 | Requirement already satisfied: pip in ./venv/lib/python3.9/site-packages (21.1.1)
 43 | Collecting pip
 44 |   Using cached pip-22.2.2-py3-none-any.whl (2.0 MB)
 45 | Requirement already satisfied: setuptools in ./venv/lib/python3.9/site-packages (56.0.0)
 46 | Collecting setuptools
 47 |   Using cached setuptools-65.3.0-py3-none-any.whl (1.2 MB)
 48 | Collecting wheel
 49 |   Using cached wheel-0.37.1-py2.py3-none-any.whl (35 kB)
 50 | Installing collected packages: wheel, setuptools, pip
 51 |   Attempting uninstall: setuptools
 52 |     Found existing installation: setuptools 56.0.0
 53 |     Uninstalling setuptools-56.0.0:
 54 |       Successfully uninstalled setuptools-56.0.0
 55 |   Attempting uninstall: pip
 56 |     Found existing installation: pip 21.1.1
 57 |     Uninstalling pip-21.1.1:
 58 |       Successfully uninstalled pip-21.1.1
 59 | Successfully installed pip-22.2.2 setuptools-65.3.0 wheel-0.37.1
 60 | 
61 | 62 | 4. Now install the prerequisite packages for this application using the ```pip install``` command. Add the ```-r``` option to instruct ```pip``` that requirements will be loaded from a file, and supply the path to this application's ```requirements.txt``` file: 63 | 64 |
 65 | (venv) $ pip install -r netbox-webhook-automation/requirements.txt
 66 | Collecting Flask~=2.2.2
 67 |   Using cached Flask-2.2.2-py3-none-any.whl (101 kB)
 68 | Collecting pynetbox~=6.6.2
 69 |   Using cached pynetbox-6.6.2-py3-none-any.whl (32 kB)
 70 | ... (output truncated) ...
 71 | Successfully installed Flask-2.2.2 Jinja2-3.1.2 MarkupSafe-2.1.1 Werkzeug-2.2.2 certifi-2022.9.14
 72 | charset-normalizer-2.1.1 click-8.1.3 idna-3.4 importlib-metadata-4.12.0 itsdangerous-2.1.2
 73 | pynetbox-6.6.2 requests-2.28.1 requests-toolbelt-0.9.1 six-1.16.0 urllib3-1.26.12 zipp-3.8.1
 74 | 
75 | 76 | ### Configure the application: 77 | 78 | 1. Change to the ```netbox-webhook-automation/webhook_listener``` directory: 79 |
 80 | (venv) $ cd netbox-webhook-automation/webhook_listener 
 81 | 
82 | 83 | 2. Copy the ```credentials.py.dist``` file to ```credentials.py```: 84 |
 85 | (venv) $ cp credentials.py.dist credentials.py
 86 | 
87 | 88 | 3. Use your editor of choice to open the ```credentials.py``` file. Change the NetBox URL and API token to match your environment, and define credentials for the destination virtual device(s). *Note: as this application is for demonstration purposes, only a single credential will be used. The user associated with this credential must have privilege level 15 on the remote device(s).* 89 | 90 | ```python 91 | NETBOX_URL = "https://netbox" 92 | NETBOX_TOKEN = "0123456789abcdef0123456789abcdef01234567" 93 | DEVICE_USERNAME = "developer" 94 | DEVICE_PASSWORD = "1234QWer" 95 | ``` 96 | 97 | 4. The default port for the Flask webhook listener is ```19703```. If you wish to change this, open the ```main.py``` file in your editor and modify the ```app.run``` parameters in the main section: 98 | 99 |
100 | if __name__ == "__main__":
101 |     app.debug = True
102 |     app.run(host="0.0.0.0", port=19703)
103 | 
104 | 105 | ### Start the webhook listener! 106 | 1. Launch the ```main.py``` file using ```python```. The flask app will start and some output will be displayed: 107 |
108 | (venv) $ python main.py
109 |  * Serving Flask app 'main'
110 |  * Debug mode: on
111 | WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
112 |  * Running on all addresses (0.0.0.0)
113 |  * Running on http://127.0.0.1:19703
114 |  * Running on http://192.168.1.2:19703
115 | Press CTRL+C to quit
116 |  * Restarting with stat
117 |  * Debugger is active!
118 |  * Debugger PIN: 123-456-789
119 | 
120 | 121 | ## Configure NetBox webhooks 122 | ### Create a webhook for interface updates: 123 | 1. In the NetBox interface left menu bar, click **Other** and then **Webhooks** to enter the webhook configuration context: 124 | 125 | ![Webhook configuration link](https://github.com/CiscoLearning/netbox-webhook-automation/blob/main/static/img/01_netbox_webhook_menu.png) 126 | 127 | 2. On the Webhooks configuration page, click **+ Add** in the top right corner to create a new webhook. 128 | 129 | 3. Complete the following fields to add a new webhook: 130 | - Name: Provide a useful name such as **interface-webhook** 131 | - Content types: **DCIM > interface** 132 | - Enabled: **checked** to enable the webhook. 133 | - Events: **Creations**, **Updates**, and **Deletions** 134 | - URL: Enter the IP or hostname of the system running the webhook listener, the port, and the default path for the interface listener which is ```/api/update-interface```. An example URL would be **http://192.168.1.2:19703/api/update-interface** 135 | - HTTP method: **POST** 136 | - HTTP content type: **application/json** 137 | - Additional headers: None (leave empty) 138 | - Body template: None (leave empty) 139 | - Secret: None (leave empty) 140 | - Conditions: null (do not change the default) 141 | - SSL verification: irrelevant (the application is not configured to use TLS) 142 | - CA File path: None (leave empty) 143 | 144 | 4. When completed, your configuration should appear similar to the following: 145 | ![Interface webhook configuration example](https://github.com/CiscoLearning/netbox-webhook-automation/blob/main/static/img/02_interface_webhook_config.png) 146 | 147 | ### Create a webhook for IPAM updates: 148 | 1. Return to the Webhook configuration screen (**Other** -> **Webhooks**) 149 | 150 | 2. Click **+ Add** to create another webhook 151 | 152 | 3. Add a description name and follow the same steps as for the interface webhook, but note the following important differences: 153 | - Content types: **IPAM > IP address** 154 | - URL: IP/Port of the receiver but the path is **/api/update-address** 155 | 156 | 4. When complete, your webhook configuration for the IP address should appear as follows: 157 | ![IPAM webhook configuration example](https://github.com/CiscoLearning/netbox-webhook-automation/blob/main/static/img/03_ipam_webhook_config.png) 158 | 159 | ## Test it! 160 | When you create, update, or delete an IP address or an interface in NetBox, a webhook should be created and your Flask application should display some output. It might not do anything at first, which is OK! 161 | 162 | To ensure that something useful happens, verify that you have a device configured in NetBox to represent your virtual / test device and that there is a **Primary IPv4** address associated with the device. Verify that there is at least one interface associated with this device that is **not** marked as "management only." 163 | 164 | If successful, you should see output similar to the following when updating an interface: 165 | 166 | ```text 167 | Configuring interface 'GigabitEthernet3' on device 'access-rtr01'... 168 | Enabling interface. 169 | Target URL: https://198.18.1.157/restconf/data/Cisco-IOS-XE-native:native/interface/GigabitEthernet=3/shutdown 170 | Interface GigabitEthernet3 is already enabled! 171 | Response: 404 (Not Found) 172 | 173 | Setting interface description to 'Baconrific!!' 174 | URL: https://198.18.1.157/restconf/data/Cisco-IOS-XE-native:native/interface/GigabitEthernet=3/description 175 | Payload: 176 | {'description': 'Baconrific!!'} 177 | Response: 201 (Created) 178 | 179 | Setting interface MTU to '1500' 180 | URL: https://198.18.1.157/restconf/data/Cisco-IOS-XE-native:native/interface/GigabitEthernet=3/mtu 181 | Payload: 182 | {'mtu': 1500} 183 | Response: 201 (Created) 184 | 185 | 198.18.1.146 - - [19/Sep/2022 14:42:16] "POST /api/update-interface HTTP/1.1" 204 - 186 | ``` 187 | 188 | And output similar to the following when updating an IP address. In this example, an address is being changed from interface GigabitEthernet3 on access-rtr01 to GigabitEthernet3 on access-rtr02: 189 | ```text 190 | Updating IP address... 191 | Removing address 192.168.222.222/24 from interface 'GigabitEthernet3' on device 'access-rtr01'... 192 | Response: 204 (No Content) 193 | Assigning address 192.168.222.222/24 to interface 'GigabitEthernet3' on device 'access-rtr02'... 194 | Sending payload: 195 | {'primary': {'address': '192.168.222.222', 'mask': '255.255.255.0'}} 196 | To URL: 197 | https://198.18.1.158/restconf/data/Cisco-IOS-XE-native:native/interface/GigabitEthernet=3/ip/address/primary 198 | Response: 204 (No Content) 199 | 198.18.1.146 - - [19/Sep/2022 15:05:29] "POST /api/update-address HTTP/1.1" 204 - 200 | ``` 201 | 202 | ## Have fun and keep coding! 203 | Now that you've seen how to automate network device configuration with NetBox webhooks, keep learning and experimenting! 204 | 205 | See if you can add more functionality or create your own webhook listeners for other events - perhaps for VRF definitions or subinterfaces. Your imagination is the only limit! 206 | 207 | ### Additional resources: 208 | - [NetBox-Community GitHub repository](https://github.com/netbox-community/netbox) contains the source for NetBox as well as project information 209 | - [NetBox documentation site](https://docs.netbox.dev/en/stable/) contains everything NetBox-related - from downloading to advanced configuration. 210 | - [Python Flask documentation](https://flask.palletsprojects.com/en/2.2.x/) - Flask is used as the API listener in this project. The documentation site includes quickstart guides, reference material, and code examples. 211 | - [pynetbox - Python API client library for NetBox](https://pynetbox.readthedocs.io/en/latest/) has many examples of using pynetbox to interact with NetBox using Python. 212 | - [Cisco YANG Suite](https://developer.cisco.com/yangsuite/) - Browse YANG models interactively! If you're getting started with YANG models, this tool is *very* useful. 213 | 214 | --- 215 | Original development by Palmer Sample, September 2022. Distributed under the MIT license. 216 | 217 | Please send questions, comments, or suggestions to [psample@cisco.com](mailto:psample@cisco.com?subject=[GitHub.com]%20NetBox%20Webhook%20Automation%20Repository%20Inquiry) -------------------------------------------------------------------------------- /webhook_listener/ipam_api.py: -------------------------------------------------------------------------------- 1 | """ 2 | Functions used when a Webhook request is received for Netbox IPAM (IP Address 3 | Management) objects 4 | """ 5 | # ipaddress is needed to parse incoming IP data 6 | import ipaddress 7 | 8 | # "g" represents a Flask app global variable which can be used throughout the 9 | # application. 10 | # "request" represents the Flask received data 11 | # "Response" is an object that we can use to return an HTTP status code to the 12 | # NetBox calling webhook. 13 | from flask import g, request, Response 14 | 15 | # Import the configured NetBox API object and the RESTCONF session object from 16 | # config.py 17 | from config import netbox_api, restconf_session 18 | 19 | # ... And, import the function to parse an interface name into a type and ID 20 | from common_functions import parse_interface_name 21 | 22 | 23 | def configure_interface_ipv4_address(ip_address): 24 | """ 25 | Configure an interface's primary IPv4 address. 26 | 27 | :param ip_address: IPv4 address in CIDR notation (IP/Prefix) 28 | :return: Flask HTTP/204 Response object 29 | """ 30 | 31 | # Convert the CIDR notation into an IPv4Interface object, allowing easy 32 | # parsing of the address components 33 | ip4_address = format(ipaddress.IPv4Interface(ip_address).ip) 34 | ip4_netmask = format(ipaddress.IPv4Interface(ip_address).netmask) 35 | 36 | # Build the target URL from the Flask "g" global variable containing the 37 | # base URL of the device and interface to be modified 38 | url = f"{g.base_url}/ip/address/primary" 39 | 40 | payload = { 41 | "primary": { 42 | "address": ip4_address, 43 | "mask": ip4_netmask 44 | } 45 | } 46 | 47 | print(f"Sending payload:\n\t{payload}\nTo URL:\n\t{url}") 48 | response = restconf_session.patch(url=url, json=payload) 49 | print(f"\tResponse: {response.status_code} ({response.reason})") 50 | 51 | 52 | def configure_interface_ipv6_address(ip_address): 53 | """ 54 | Because an interface may have multiple IPv6 addresses assigned, add the 55 | desired IPv6 address to the list of addresses on the target interface. 56 | 57 | :param ip_address: IPv6 address with prefix 58 | :return: Flask HTTP/204 Response object 59 | """ 60 | url = f"{g.base_url}/ipv6/address/prefix-list" 61 | 62 | payload = { 63 | "prefix-list": [ 64 | { 65 | "prefix": ip_address 66 | } 67 | ] 68 | } 69 | print(f"Sending payload:\n\t{payload}\nTo URL:\n\t{url}") 70 | response = restconf_session.patch(url=url, json=payload) 71 | print(f"\tResponse: {response.status_code} ({response.reason})") 72 | 73 | 74 | def configure_ip_address(netbox_interface_object, ip_address, address_family): 75 | """ 76 | Main function to be called if an IP address will be configured on an 77 | interface. The address family (4/6) will be used to determine which 78 | function must be called to perform the actual configuration. Almost 79 | all other relevant details such as the device management IP can be 80 | gleaned from the NetBox interface object reference. 81 | 82 | :param netbox_interface_object: pynetbox interface object reference 83 | :param ip_address: string - IPv4 or IPv6 address 84 | :param address_family: int - 4 or 6 to match the IP family 85 | :return: None 86 | """ 87 | 88 | # Parse the interface type and ID for RESTCONF URL generation 89 | interface_type, interface_id = parse_interface_name(netbox_interface_object.name) 90 | 91 | # Grab the management IP from the pynetbox interface object. Note that this 92 | # is targeting the IPv4 primary IP address, so will be wrapped into an 93 | # ipaddress.IPv4Interface object for easy extraction of the address 94 | # component 95 | mgmt_ip = format(ipaddress.IPv4Interface(netbox_interface_object.device.primary_ip).ip) 96 | 97 | # Generate the base URL for RESTCONF requests against the desired interface 98 | # Note that the Flask "g" global variable is used here to store the URL. 99 | g.base_url = f"https://{mgmt_ip}/restconf/data/Cisco-IOS-XE-native:native/interface/" \ 100 | f"{interface_type}={interface_id}" 101 | 102 | print(f"Assigning address {ip_address} " 103 | f"to interface '{netbox_interface_object.name}' " 104 | f"on device '{netbox_interface_object.device.name}'...") 105 | 106 | # Configure the address using the matching AF function 107 | if address_family == 6: 108 | configure_interface_ipv6_address(ip_address) 109 | else: 110 | configure_interface_ipv4_address(ip_address) 111 | 112 | 113 | def unconfigure_interface_ipv4_address(): 114 | """ 115 | Delete the primary IPv4 address from an interface. No parameters are needed 116 | as an HTTP DELETE is performed against the primary address RESTCONF 117 | endpoint for the device interface 118 | 119 | :return: None 120 | """ 121 | url = f"{g.base_url}/ip/address/primary" 122 | 123 | response = restconf_session.delete(url=url) 124 | print(f"\tResponse: {response.status_code} ({response.reason})") 125 | 126 | 127 | def unconfigure_interface_ipv6_address(ip_address): 128 | """ 129 | Remove the provided IPv6 address from the list of IPv6 prefixes on an 130 | interface. 131 | 132 | :param ip_address: string - IPv6 address to remove from the interface 133 | :return: None 134 | """ 135 | # Convert the "/" in the IPv6 address representation to a URL-friendly 136 | # "%2F" to avoid it being interpreted as part of the RESTCONF target 137 | # URL. 138 | formatted_address = format(ip_address).replace("/", "%2F") 139 | url = f"{g.base_url}/ipv6/address/prefix-list={formatted_address}" 140 | response = restconf_session.delete(url=url) 141 | 142 | print(f"\tResponse: {response.status_code} ({response.reason})") 143 | 144 | 145 | def unconfigure_ip_address(netbox_interface_object, ip_address, address_family): 146 | """ 147 | Primary function to remove an address from an interface. Depending on the 148 | address family, call the appropriate function to unconfigure the 149 | v4 or v6 address. 150 | 151 | :param netbox_interface_object: pynetbox interface object reference 152 | :param ip_address: string - IP address in CIDR notation 153 | :param address_family: int - IP address family (4|6) 154 | :return: None 155 | """ 156 | 157 | interface_type, interface_id = parse_interface_name(netbox_interface_object.name) 158 | mgmt_ip = format(ipaddress.IPv4Interface(netbox_interface_object.device.primary_ip).ip) 159 | 160 | g.base_url = f"https://{mgmt_ip}/restconf/data/Cisco-IOS-XE-native:native/interface/" \ 161 | f"{interface_type}={interface_id}" 162 | 163 | print(f"Removing address {ip_address} " 164 | f"from interface '{netbox_interface_object.name}' " 165 | f"on device '{netbox_interface_object.device.name}'...") 166 | 167 | if address_family == 6: 168 | unconfigure_interface_ipv6_address(ip_address) 169 | else: 170 | unconfigure_interface_ipv4_address() 171 | 172 | 173 | def update_ip_address(netbox_interface_object, snapshot_json, ip_address, address_family): 174 | """ 175 | If an IPAM webhook is received indicating the address has been updated and 176 | there is a snapshot key in the webhook payload, it's possible that the 177 | address was previously configured on a different interface and/or a 178 | different device. 179 | 180 | Parse the snapshot payload, check if the target interface/device matches 181 | the "prechange" snapshot. If so, only configure the address on the target 182 | interface. 183 | 184 | If the presnapshot data differs from the target interface, unconfigure the 185 | previously-configured interface before configuring the address on the 186 | target. 187 | 188 | :param netbox_interface_object: pynetbox interface object reference 189 | :param snapshot_json: Contents of the webhook "snapshot" payload 190 | :param ip_address: string - IP address in CIDR notation 191 | :param address_family: int - IP address family (4|6) 192 | :return: None 193 | """ 194 | print("Updating IP address...") 195 | 196 | # If the snapshot_json is not None, the snapshot must be compared to the 197 | # webhook data contained in the pynetbox interface object reference. 198 | if snapshot_json: 199 | try: 200 | old_interface_id = snapshot_json["prechange"]["assigned_object_id"] 201 | 202 | if old_interface_id != netbox_interface_object.id: 203 | # Old assignment is on a different interface. Unconfigure 204 | # before configuring the new device. 205 | old_interface_data = netbox_api.dcim.interfaces.get(old_interface_id) 206 | if not old_interface_data.mgmt_only: 207 | unconfigure_ip_address(netbox_interface_object=old_interface_data, 208 | ip_address=ip_address, 209 | address_family=address_family) 210 | except AttributeError: 211 | print("Address not previously assigned") 212 | except ValueError: 213 | print("Address not previously assigned") 214 | 215 | # Regardless of the previous address assignment, it is time to configure 216 | # the address on the target interface... 217 | configure_ip_address(netbox_interface_object=netbox_interface_object, 218 | ip_address=ip_address, 219 | address_family=address_family) 220 | 221 | 222 | def manage_interface_ip_address(): 223 | """ 224 | Function for the Webhook listener at the IPAM configuration path. 225 | 226 | When an IPAM webhook is received, obtain the IP address and address 227 | family from the payload data. If there is an assigned interface for the 228 | IP address, determine if it's a new address, an existing address to be 229 | deleted, or an existing address to be updated and call the appropriate 230 | function to handle the requested task. 231 | 232 | Note: if the address is assigned to the designated device management 233 | interface, no action will be performed. 234 | 235 | :return: A generic HTTP 204 response via Flask Response object 236 | """ 237 | 238 | ip_address = request.json["data"]["address"] 239 | address_family = request.json["data"]["family"]["value"] 240 | 241 | if assigned_interface := request.json["data"].get("assigned_object_id"): 242 | # There is an interface assigned to this object. Get the interface details 243 | # and determine what type of request this is (created, updated, deleted) 244 | # to perform the expected action. 245 | 246 | assigned_interface_details = netbox_api.dcim.interfaces.get(assigned_interface) 247 | 248 | if assigned_interface_details.mgmt_only: 249 | print("\tManagement interface, no changes will be performed...") 250 | else: 251 | 252 | if request.json["event"] == "deleted": 253 | # The IP address has been deleted from NetBox. Unconfigure it from the 254 | # currently-assigned interface. 255 | 256 | unconfigure_ip_address(netbox_interface_object=assigned_interface_details, 257 | ip_address=ip_address, 258 | address_family=address_family) 259 | 260 | elif request.json["event"] == "created": 261 | # This is a newly-created IP address. Configure it on the assigned 262 | # interface. 263 | configure_ip_address(netbox_interface_object=assigned_interface_details, 264 | ip_address=ip_address, 265 | address_family=address_family) 266 | 267 | elif request.json["event"] == "updated": 268 | # Details of the IP have changed. Determine if it was previously 269 | # assigned to a different device / interface. If so, unconfigure 270 | # that interface first and configure on the new one. 271 | update_ip_address(netbox_interface_object=assigned_interface_details, 272 | snapshot_json=request.json.get("snapshots"), 273 | ip_address=ip_address, 274 | address_family=address_family) 275 | 276 | return Response(status=204) 277 | --------------------------------------------------------------------------------