Explorar o código

Enable custom templating for webhook request content

Jeremy Stretch %!s(int64=6) %!d(string=hai) anos
pai
achega
99038ffc44

+ 17 - 3
netbox/extras/admin.py

@@ -26,7 +26,7 @@ class WebhookForm(forms.ModelForm):
 
 
     class Meta:
     class Meta:
         model = Webhook
         model = Webhook
-        exclude = []
+        exclude = ()
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
@@ -38,13 +38,27 @@ class WebhookForm(forms.ModelForm):
 @admin.register(Webhook, site=admin_site)
 @admin.register(Webhook, site=admin_site)
 class WebhookAdmin(admin.ModelAdmin):
 class WebhookAdmin(admin.ModelAdmin):
     list_display = [
     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 = [
     list_filter = [
         'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
         'enabled', 'type_create', 'type_update', 'type_delete', 'obj_type',
     ]
     ]
     form = WebhookForm
     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):
     def models(self, obj):
         return ', '.join([ct.name for ct in obj.obj_type.all()])
         return ', '.join([ct.name for ct in obj.obj_type.all()])

+ 0 - 20
netbox/extras/choices.py

@@ -118,23 +118,3 @@ class TemplateLanguageChoices(ChoiceSet):
         LANGUAGE_DJANGO: 10,
         LANGUAGE_DJANGO: 10,
         LANGUAGE_JINJA2: 20,
         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,
-    }

+ 2 - 0
netbox/extras/constants.py

@@ -138,6 +138,8 @@ LOG_LEVEL_CODES = {
     LOG_FAILURE: 'failure',
     LOG_FAILURE: 'failure',
 }
 }
 
 
+HTTP_CONTENT_TYPE_JSON = 'application/json'
+
 # Models which support registered webhooks
 # Models which support registered webhooks
 WEBHOOK_MODELS = Q(
 WEBHOOK_MODELS = Q(
     Q(app_label='circuits', model__in=[
     Q(app_label='circuits', model__in=[

+ 23 - 0
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),
+        ),
+    ]

+ 15 - 11
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.
     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.
     Each Webhook can be limited to firing only on certain actions or certain object types.
     """
     """
-
     obj_type = models.ManyToManyField(
     obj_type = models.ManyToManyField(
         to=ContentType,
         to=ContentType,
         related_name='webhooks',
         related_name='webhooks',
@@ -81,11 +80,15 @@ class Webhook(models.Model):
         verbose_name='URL',
         verbose_name='URL',
         help_text="A POST will be sent to this URL when the webhook is called."
         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(
     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 '
+                  '<a href="https://www.iana.org/assignments/media-types/media-types.xhtml">here</a>.'
     )
     )
     additional_headers = JSONField(
     additional_headers = JSONField(
         null=True,
         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. "
         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."
                   "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: <code>event</code>, '
+                  '<code>timestamp</code>, <code>model</code>, <code>username</code>, <code>request_id</code>, and '
+                  '<code>data</code>.'
+    )
     secret = models.CharField(
     secret = models.CharField(
         max_length=255,
         max_length=255,
         blank=True,
         blank=True,
@@ -101,9 +111,6 @@ class Webhook(models.Model):
                   "the secret as the key. The secret is not transmitted in "
                   "the secret as the key. The secret is not transmitted in "
                   "the request."
                   "the request."
     )
     )
-    enabled = models.BooleanField(
-        default=True
-    )
     ssl_verification = models.BooleanField(
     ssl_verification = models.BooleanField(
         default=True,
         default=True,
         verbose_name='SSL verification',
         verbose_name='SSL verification',
@@ -126,9 +133,6 @@ class Webhook(models.Model):
         return self.name
         return self.name
 
 
     def clean(self):
     def clean(self):
-        """
-        Validate model
-        """
         if not self.type_create and not self.type_delete and not self.type_update:
         if not self.type_create and not self.type_delete and not self.type_update:
             raise ValidationError(
             raise ValidationError(
                 "You must select at least one type: create, update, and/or delete."
                 "You must select at least one type: create, update, and/or delete."

+ 0 - 1
netbox/extras/webhooks.py

@@ -1,4 +1,3 @@
-import datetime
 import hashlib
 import hashlib
 import hmac
 import hmac
 
 

+ 30 - 6
netbox/extras/webhooks_worker.py

@@ -1,19 +1,25 @@
 import json
 import json
+import logging
 
 
 import requests
 import requests
 from django_rq import job
 from django_rq import job
+from jinja2.exceptions import TemplateError
 from rest_framework.utils.encoders import JSONEncoder
 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
 from .webhooks import generate_signature
 
 
+logger = logging.getLogger('netbox.webhooks_worker')
+
 
 
 @job('default')
 @job('default')
 def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
 def process_webhook(webhook, data, model_name, event, timestamp, username, request_id):
     """
     """
     Make a POST request to the defined Webhook
     Make a POST request to the defined Webhook
     """
     """
-    payload = {
+    context = {
         'event': dict(ObjectChangeActionChoices)[event].lower(),
         'event': dict(ObjectChangeActionChoices)[event].lower(),
         'timestamp': timestamp,
         'timestamp': timestamp,
         'model': model_name,
         'model': model_name,
@@ -21,6 +27,8 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
         'request_id': request_id,
         'request_id': request_id,
         'data': data
         'data': data
     }
     }
+
+    # Build HTTP headers
     headers = {
     headers = {
         'Content-Type': webhook.http_content_type,
         'Content-Type': webhook.http_content_type,
     }
     }
@@ -33,10 +41,22 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
         'headers': headers
         '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()
     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
             session.verify = webhook.ca_file_path
         response = session.send(prepared_request)
         response = session.send(prepared_request)
 
 
+    logger.debug(params)
+
     if 200 <= response.status_code <= 299:
     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)
         return 'Status {} returned, webhook successfully processed.'.format(response.status_code)
     else:
     else:
+        logger.error("Request failed; response status {}: {}".format(response.status_code, response.content))
         raise requests.exceptions.RequestException(
         raise requests.exceptions.RequestException(
             "Status {} returned with content '{}', webhook FAILED to process.".format(
             "Status {} returned with content '{}', webhook FAILED to process.".format(
                 response.status_code, response.content
                 response.status_code, response.content