1
0
mirror of https://github.com/netbox-community/netbox.git synced 2024-05-10 07:54:54 +00:00

Enable custom templating for webhook request content

This commit is contained in:
Jeremy Stretch
2020-02-24 16:12:46 -05:00
parent 81d001d49e
commit 99038ffc44
7 changed files with 87 additions and 41 deletions

View File

@ -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', 'name', 'models', 'payload_url', 'http_content_type', 'enabled', 'type_create', 'type_update', 'type_delete',
'type_delete', 'ssl_verification', '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()])

View File

@ -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,
}

View File

@ -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=[

View File

@ -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),
),
]

View File

@ -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, max_length=100,
choices=WebhookContentTypeChoices, default=HTTP_CONTENT_TYPE_JSON,
default=WebhookContentTypeChoices.CONTENTTYPE_JSON, verbose_name='HTTP content type',
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."

View File

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

View File

@ -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: logger.info(
params.update({'data': json.dumps(payload, cls=JSONEncoder)}) "Sending webhook to {}: {} {}".format(params['url'], context['model'], context['event'])
elif webhook.http_content_type == WebhookContentTypeChoices.CONTENTTYPE_FORMDATA: )
params.update({'data': payload})
# 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