From 99038ffc44d9e0c12fb801ea102fdb22581e1041 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 24 Feb 2020 16:12:46 -0500 Subject: [PATCH] Enable custom templating for webhook request content --- netbox/extras/admin.py | 20 +++++++++-- netbox/extras/choices.py | 20 ----------- netbox/extras/constants.py | 2 ++ .../migrations/0038_webhook_body_template.py | 23 ++++++++++++ netbox/extras/models.py | 26 ++++++++------ netbox/extras/webhooks.py | 1 - netbox/extras/webhooks_worker.py | 36 +++++++++++++++---- 7 files changed, 87 insertions(+), 41 deletions(-) create mode 100644 netbox/extras/migrations/0038_webhook_body_template.py diff --git a/netbox/extras/admin.py b/netbox/extras/admin.py index 2a39c207e..9a75fb53f 100644 --- a/netbox/extras/admin.py +++ b/netbox/extras/admin.py @@ -26,7 +26,7 @@ class WebhookForm(forms.ModelForm): class Meta: model = Webhook - exclude = [] + exclude = () def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -38,13 +38,27 @@ class WebhookForm(forms.ModelForm): @admin.register(Webhook, site=admin_site) class WebhookAdmin(admin.ModelAdmin): list_display = [ - 'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', - 'type_delete', 'ssl_verification', + 'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete', + 'ssl_verification', ] list_filter = [ 'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type', ] form = WebhookForm + fieldsets = ( + (None, { + 'fields': ('name', 'obj_type', 'enabled') + }), + ('Events', { + 'fields': ('type_create', 'type_update', 'type_delete') + }), + ('HTTP Request', { + 'fields': ('payload_url', 'http_content_type', 'additional_headers', 'body_template', 'secret') + }), + ('SSL', { + 'fields': ('ssl_verification', 'ca_file_path') + }) + ) def models(self, obj): return ', '.join([ct.name for ct in obj.obj_type.all()]) diff --git a/netbox/extras/choices.py b/netbox/extras/choices.py index 0ae53f03d..434461e76 100644 --- a/netbox/extras/choices.py +++ b/netbox/extras/choices.py @@ -118,23 +118,3 @@ class TemplateLanguageChoices(ChoiceSet): LANGUAGE_DJANGO: 10, LANGUAGE_JINJA2: 20, } - - -# -# Webhooks -# - -class WebhookContentTypeChoices(ChoiceSet): - - CONTENTTYPE_JSON = 'application/json' - CONTENTTYPE_FORMDATA = 'application/x-www-form-urlencoded' - - CHOICES = ( - (CONTENTTYPE_JSON, 'JSON'), - (CONTENTTYPE_FORMDATA, 'Form data'), - ) - - LEGACY_MAP = { - CONTENTTYPE_JSON: 1, - CONTENTTYPE_FORMDATA: 2, - } diff --git a/netbox/extras/constants.py b/netbox/extras/constants.py index b12bc2f2c..7bb026d34 100644 --- a/netbox/extras/constants.py +++ b/netbox/extras/constants.py @@ -138,6 +138,8 @@ LOG_LEVEL_CODES = { LOG_FAILURE: 'failure', } +HTTP_CONTENT_TYPE_JSON = 'application/json' + # Models which support registered webhooks WEBHOOK_MODELS = Q( Q(app_label='circuits', model__in=[ diff --git a/netbox/extras/migrations/0038_webhook_body_template.py b/netbox/extras/migrations/0038_webhook_body_template.py new file mode 100644 index 000000000..1e23a2303 --- /dev/null +++ b/netbox/extras/migrations/0038_webhook_body_template.py @@ -0,0 +1,23 @@ +# Generated by Django 2.2.10 on 2020-02-24 20:12 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('extras', '0037_configcontexts_clusters'), + ] + + operations = [ + migrations.AddField( + model_name='webhook', + name='body_template', + field=models.TextField(blank=True), + ), + migrations.AlterField( + model_name='webhook', + name='http_content_type', + field=models.CharField(default='application/json', max_length=100), + ), + ] diff --git a/netbox/extras/models.py b/netbox/extras/models.py index 5d175d172..896595524 100644 --- a/netbox/extras/models.py +++ b/netbox/extras/models.py @@ -52,7 +52,6 @@ class Webhook(models.Model): delete in NetBox. The request will contain a representation of the object, which the remote application can act on. Each Webhook can be limited to firing only on certain actions or certain object types. """ - obj_type = models.ManyToManyField( to=ContentType, related_name='webhooks', @@ -81,11 +80,15 @@ class Webhook(models.Model): verbose_name='URL', help_text="A POST will be sent to this URL when the webhook is called." ) + enabled = models.BooleanField( + default=True + ) http_content_type = models.CharField( - max_length=50, - choices=WebhookContentTypeChoices, - default=WebhookContentTypeChoices.CONTENTTYPE_JSON, - verbose_name='HTTP content type' + max_length=100, + default=HTTP_CONTENT_TYPE_JSON, + verbose_name='HTTP content type', + help_text='The complete list of official content types is available ' + 'here.' ) additional_headers = JSONField( null=True, @@ -93,6 +96,13 @@ class Webhook(models.Model): help_text="User supplied headers which should be added to the request in addition to the HTTP content type. " "Headers are supplied as key/value pairs in a JSON object." ) + body_template = models.TextField( + blank=True, + help_text='Jinja2 template for a custom request body. If blank, a JSON object or form data representing the ' + 'change will be included. Available context data includes: event, ' + 'timestamp, model, username, request_id, and ' + 'data.' + ) secret = models.CharField( max_length=255, blank=True, @@ -101,9 +111,6 @@ class Webhook(models.Model): "the secret as the key. The secret is not transmitted in " "the request." ) - enabled = models.BooleanField( - default=True - ) ssl_verification = models.BooleanField( default=True, verbose_name='SSL verification', @@ -126,9 +133,6 @@ class Webhook(models.Model): return self.name def clean(self): - """ - Validate model - """ if not self.type_create and not self.type_delete and not self.type_update: raise ValidationError( "You must select at least one type: create, update, and/or delete." diff --git a/netbox/extras/webhooks.py b/netbox/extras/webhooks.py index cfa05d0f6..8b20641d7 100644 --- a/netbox/extras/webhooks.py +++ b/netbox/extras/webhooks.py @@ -1,4 +1,3 @@ -import datetime import hashlib import hmac diff --git a/netbox/extras/webhooks_worker.py b/netbox/extras/webhooks_worker.py index e48d8a2d7..b91561846 100644 --- a/netbox/extras/webhooks_worker.py +++ b/netbox/extras/webhooks_worker.py @@ -1,19 +1,25 @@ import json +import logging import requests from django_rq import job +from jinja2.exceptions import TemplateError from rest_framework.utils.encoders import JSONEncoder -from .choices import ObjectChangeActionChoices, WebhookContentTypeChoices +from utilities.utils import render_jinja2 +from .choices import ObjectChangeActionChoices +from .constants import HTTP_CONTENT_TYPE_JSON from .webhooks import generate_signature +logger = logging.getLogger('netbox.webhooks_worker') + @job('default') def process_webhook(webhook, data, model_name, event, timestamp, username, request_id): """ Make a POST request to the defined Webhook """ - payload = { + context = { 'event': dict(ObjectChangeActionChoices)[event].lower(), 'timestamp': timestamp, 'model': model_name, @@ -21,6 +27,8 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque 'request_id': request_id, 'data': data } + + # Build HTTP headers headers = { 'Content-Type': webhook.http_content_type, } @@ -33,10 +41,22 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque 'headers': headers } - if webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_JSON: - params.update({'data': json.dumps(payload, cls=JSONEncoder)}) - elif webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_FORMDATA: - params.update({'data': payload}) + logger.info( + "Sending webhook to {}: {} {}".format(params['url'], context['model'], context['event']) + ) + + # Construct the request body. If a template has been defined, use it. Otherwise, dump the context as either JSON + # or form data. + if webhook.body_template: + try: + params['data'] = render_jinja2(webhook.body_template, context) + except TemplateError as e: + logger.error("Error rendering request body: {}".format(e)) + return + elif webhook.http_content_type == HTTP_CONTENT_TYPE_JSON: + params['data'] = json.dumps(context, cls=JSONEncoder) + else: + params['data'] = context prepared_request = requests.Request(**params).prepare() @@ -50,9 +70,13 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque session.verify = webhook.ca_file_path response = session.send(prepared_request) + logger.debug(params) + if 200 <= response.status_code <= 299: + logger.info("Request succeeded; response status {}".format(response.status_code)) return 'Status {} returned, webhook successfully processed.'.format(response.status_code) else: + logger.error("Request failed; response status {}: {}".format(response.status_code, response.content)) raise requests.exceptions.RequestException( "Status {} returned with content '{}', webhook FAILED to process.".format( response.status_code, response.content