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