webhooks.py 5.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. import hashlib
  2. import hmac
  3. import logging
  4. import requests
  5. from django.conf import settings
  6. from django_rq import job
  7. from jinja2.exceptions import TemplateError
  8. from netbox.registry import registry
  9. from netbox.settings_utils import parse_job_timeout
  10. from utilities.proxy import resolve_proxies
  11. from utilities.request import get_safe_request_context
  12. from .constants import WEBHOOK_EVENT_TYPES
  13. __all__ = (
  14. 'generate_signature',
  15. 'register_webhook_callback',
  16. 'send_webhook',
  17. )
  18. logger = logging.getLogger('netbox.webhooks')
  19. def register_webhook_callback(func):
  20. """
  21. Register a function as a webhook callback.
  22. """
  23. registry['webhook_callbacks'].append(func)
  24. logger.debug(f'Registered webhook callback {func.__module__}.{func.__name__}')
  25. return func
  26. def generate_signature(request_body, secret):
  27. """
  28. Return a cryptographic signature that can be used to verify the authenticity of webhook data.
  29. """
  30. hmac_prep = hmac.new(
  31. key=secret.encode('utf8'),
  32. msg=request_body,
  33. digestmod=hashlib.sha512
  34. )
  35. return hmac_prep.hexdigest()
  36. @job('default')
  37. def send_webhook(
  38. event_rule, object_type, event_type, data, timestamp, request=None, snapshots=None, object_change_ids=None
  39. ):
  40. """
  41. Make a POST request to the defined Webhook
  42. """
  43. webhook = event_rule.action_object
  44. # Prepare context data for headers & body templates
  45. context = {
  46. 'event': WEBHOOK_EVENT_TYPES.get(event_type, event_type),
  47. 'timestamp': timestamp,
  48. 'object_type': '.'.join(object_type.natural_key()),
  49. 'data': data,
  50. }
  51. if request:
  52. context['request'] = get_safe_request_context(request)
  53. if snapshots:
  54. context['snapshots'] = snapshots
  55. if object_change_ids:
  56. context['object_change_ids'] = object_change_ids
  57. # Add any additional context from plugins
  58. callback_data = {}
  59. for callback in registry['webhook_callbacks']:
  60. try:
  61. if ret := callback(object_type, event_type, data, request):
  62. callback_data.update(**ret)
  63. except Exception as e:
  64. logger.warning(f"Caught exception when processing callback {callback}: {e}")
  65. pass
  66. if callback_data:
  67. context['context'] = callback_data
  68. # Build the headers for the HTTP request
  69. headers = {
  70. 'Content-Type': webhook.http_content_type,
  71. }
  72. try:
  73. headers.update(webhook.render_headers(context))
  74. except (TemplateError, ValueError) as e:
  75. logger.error(f"Error parsing HTTP headers for webhook {webhook}: {e}")
  76. raise e
  77. # Render the request body
  78. try:
  79. body = webhook.render_body(context)
  80. except TemplateError as e:
  81. logger.error(f"Error rendering request body for webhook {webhook}: {e}")
  82. raise e
  83. # Prepare the HTTP request
  84. url = webhook.render_payload_url(context)
  85. params = {
  86. 'method': webhook.http_method,
  87. 'url': url,
  88. 'headers': headers,
  89. 'data': body.encode('utf8'),
  90. }
  91. logger.info(
  92. f"Sending {params['method']} request to {params['url']} ({context['object_type']} {context['event']})"
  93. )
  94. logger.debug(params)
  95. try:
  96. prepared_request = requests.Request(**params).prepare()
  97. except requests.exceptions.RequestException as e:
  98. logger.error(f"Error forming HTTP request: {e}")
  99. raise e
  100. # If a secret key is defined, sign the request with a hash of the key and its content
  101. if webhook.secret != '':
  102. prepared_request.headers['X-Hook-Signature'] = generate_signature(prepared_request.body, webhook.secret)
  103. # Determine the request timeout, preferring the webhook-specific value over the global default
  104. timeout = webhook.timeout if webhook.timeout is not None else settings.WEBHOOK_DEFAULT_TIMEOUT
  105. # Webhook.clean() enforces this when the webhook is saved, but RQ_DEFAULT_TIMEOUT may have been lowered since.
  106. job_timeout = parse_job_timeout(settings.RQ_DEFAULT_TIMEOUT)
  107. if job_timeout is not None and timeout >= job_timeout:
  108. logger.warning(
  109. f"Webhook timeout ({timeout} seconds) is not less than the background job timeout ({job_timeout} "
  110. f"seconds); the job may be terminated before the request can time out."
  111. )
  112. # Send the request
  113. with requests.Session() as session:
  114. session.verify = webhook.ssl_verification
  115. if webhook.ca_file_path:
  116. session.verify = webhook.ca_file_path
  117. proxies = resolve_proxies(url=url, context={'client': webhook})
  118. try:
  119. response = session.send(prepared_request, proxies=proxies, timeout=timeout)
  120. except requests.exceptions.Timeout:
  121. logger.error(f"Request to {url} timed out after {timeout} seconds")
  122. raise
  123. if 200 <= response.status_code <= 299:
  124. logger.info(f"Request succeeded; response status {response.status_code}")
  125. return f"Status {response.status_code} returned, webhook successfully processed."
  126. logger.warning(f"Request failed; response status {response.status_code}: {response.content}")
  127. raise requests.exceptions.RequestException(
  128. f"Status {response.status_code} returned with content '{response.content}', webhook FAILED to process."
  129. )