Extend templatization ability to additional_headers field

This commit is contained in:
Jeremy Stretch 2020-02-24 17:47:17 -05:00
parent 1fbd3a2c26
commit 9a532b1eb2
3 changed files with 52 additions and 37 deletions

View File

@ -1,3 +1,4 @@
import json
from collections import OrderedDict from collections import OrderedDict
from datetime import date from datetime import date
@ -12,6 +13,7 @@ from django.http import HttpResponse
from django.template import Template, Context from django.template import Template, Context
from django.urls import reverse from django.urls import reverse
from django.utils.text import slugify from django.utils.text import slugify
from rest_framework.utils.encoders import JSONEncoder
from taggit.models import TagBase, GenericTaggedItemBase from taggit.models import TagBase, GenericTaggedItemBase
from utilities.fields import ColorField from utilities.fields import ColorField
@ -92,8 +94,9 @@ class Webhook(models.Model):
) )
additional_headers = models.TextField( additional_headers = models.TextField(
blank=True, blank=True,
help_text="User supplied headers which should be added to the request in addition to the HTTP content type. " help_text="User-supplied HTTP headers to be sent with the request in addition to the HTTP content type. "
"Headers are supplied as key/value pairs in a JSON object." "Headers should be defined in the format <code>Name: Value</code>. Jinja2 template processing is "
"support with the same context as the request body (below)."
) )
body_template = models.TextField( body_template = models.TextField(
blank=True, blank=True,
@ -139,14 +142,29 @@ class Webhook(models.Model):
if not self.ssl_verification and self.ca_file_path: if not self.ssl_verification and self.ca_file_path:
raise ValidationError({ raise ValidationError({
'ca_file_path': 'Do not specify a CA certificate file if SSL verification is dissabled.' 'ca_file_path': 'Do not specify a CA certificate file if SSL verification is disabled.'
}) })
# Verify that JSON data is provided as an object def render_headers(self, context):
if self.additional_headers and type(self.additional_headers) is not dict: """
raise ValidationError({ Render additional_headers and return a dict of Header: Value pairs.
'additional_headers': 'Header JSON data must be in object form. Example: {"X-API-KEY": "abc123"}' """
}) if not self.additional_headers:
return {}
ret = {}
data = render_jinja2(self.additional_headers, context)
for line in data.splitlines():
header, value = line.split(':')
ret[header.strip()] = value.strip()
return ret
def render_body(self, context):
if self.body_template:
return render_jinja2(self.body_template, context)
elif self.http_content_type == HTTP_CONTENT_TYPE_JSON:
return json.dumps(context, cls=JSONEncoder)
else:
return context
# #

View File

@ -34,7 +34,7 @@ class WebhookTest(APITestCase):
DUMMY_SECRET = "LOOKATMEIMASECRETSTRING" DUMMY_SECRET = "LOOKATMEIMASECRETSTRING"
webhooks = Webhook.objects.bulk_create(( webhooks = Webhook.objects.bulk_create((
Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers={'X-Foo': 'Bar'}), Webhook(name='Site Create Webhook', type_create=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET, additional_headers='X-Foo: Bar'),
Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), Webhook(name='Site Update Webhook', type_update=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET), Webhook(name='Site Delete Webhook', type_delete=True, payload_url=DUMMY_URL, secret=DUMMY_SECRET),
)) ))

View File

@ -1,14 +1,10 @@
import json
import logging import logging
import requests import requests
from django_rq import job from django_rq import job
from jinja2.exceptions import TemplateError from jinja2.exceptions import TemplateError
from rest_framework.utils.encoders import JSONEncoder
from utilities.utils import render_jinja2
from .choices import ObjectChangeActionChoices 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') logger = logging.getLogger('netbox.webhooks_worker')
@ -28,55 +24,56 @@ def process_webhook(webhook, data, model_name, event, timestamp, username, reque
'data': data 'data': data
} }
# Build HTTP headers # Build the headers for the HTTP request
headers = { headers = {
'Content-Type': webhook.http_content_type, 'Content-Type': webhook.http_content_type,
} }
if webhook.additional_headers: try:
headers.update(webhook.additional_headers) headers.update(webhook.render_headers(context))
except (TemplateError, ValueError) as e:
logger.error("Error parsing HTTP headers for webhook {}: {}".format(webhook, e))
raise e
# Render the request body
try:
body = webhook.render_body(context)
except TemplateError as e:
logger.error("Error rendering request body for webhook {}: {}".format(webhook, e))
raise e
# Prepare the HTTP request
params = { params = {
'method': 'POST', 'method': 'POST',
'url': webhook.payload_url, 'url': webhook.payload_url,
'headers': headers 'headers': headers,
'data': body,
} }
logger.info( logger.info(
"Sending webhook to {}: {} {}".format(params['url'], context['model'], context['event']) "Sending webhook to {}: {} {}".format(params['url'], context['model'], context['event'])
) )
logger.debug(params)
# 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: 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()
except requests.exceptions.RequestException as e:
logger.error("Error forming HTTP request: {}".format(e))
raise e
# If a secret key is defined, sign the request with a hash of the key and its content
if webhook.secret != '': if webhook.secret != '':
# Sign the request with a hash of the secret key and its content.
prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret) prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
# Send the request
with requests.Session() as session: with requests.Session() as session:
session.verify = webhook.ssl_verification session.verify = webhook.ssl_verification
if webhook.ca_file_path: if webhook.ca_file_path:
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)) 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)) logger.warning("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