├── .gitignore ├── .pre-commit-config.yaml ├── LICENSE ├── README.md ├── docs └── images │ ├── webhook_receiver.png │ ├── webhook_receiver_group.png │ └── webhook_receivers.png ├── netbox_webhook_receiver ├── __init__.py ├── api │ ├── __init__.py │ ├── serializers.py │ ├── urls.py │ └── views.py ├── choices.py ├── filtersets.py ├── forms.py ├── migrations │ ├── 0001_initial.py │ ├── 0002_webhookreceiver_hash_algorithm.py │ └── __init__.py ├── models.py ├── navigation.py ├── tables.py ├── templates │ └── netbox_webhook_receiver │ │ ├── webhookreceiver.html │ │ └── webhookreceivergroup.html ├── urls.py └── views │ ├── __init__.py │ ├── views.py │ ├── webhookreceiver.py │ └── webhookreceivergroup.py └── pyproject.toml /.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | .env 3 | .python-version 4 | poetry.lock 5 | dist/ 6 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | rev: v0.0.275 4 | hooks: 5 | - id: ruff 6 | args: [--fix, --exit-non-zero-on-fix] 7 | exclude_types: [] 8 | - repo: https://github.com/pre-commit/pre-commit-hooks 9 | rev: v4.4.0 10 | hooks: 11 | - id: debug-statements 12 | - id: end-of-file-fixer 13 | - id: trailing-whitespace 14 | - repo: https://github.com/psf/black 15 | rev: 23.3.0 16 | hooks: 17 | - id: black 18 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Aurora Research Lab 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

NetBox Webhook Receiver

2 | 3 |

NetBox Webhook Receiver is a NetBox plugin for managing webhook receiver endpoints and executing assigned actions.

4 | 5 | This plugin aims mainly to streamline the deployment process of scripts and export templates to Netbox by triggering the synchronisation of its Data Sources through incoming webhooks. Adding the receiver capability to Netbox opens other possible use cases, which might be worth exploring further. One example could be running Netbox Scripts triggered by remote events where it is not easy to execute a direct API call. 6 | 7 | ## Features 8 | - [x] Per endpoint authentication (signature validation or custom header) on receiving webhooks 9 | - [x] Webhook url enriched with automaticaly generated uuid value for additional security 10 | - [x] Dedicated view to configure each webhook receiver url 11 | - [x] Logical grouping of the receivers 12 | - [x] Optionally store incoming webhook payload 13 | - [x] Custom actions execution on successfully receving authenticated webhook message. Currently only one action available: Synchronize Netbox git 'Data Source' 14 | - [x] Use of standard Netbox jobs to queue webhook actions in the workers. 15 | - [ ] Additional actions to be implemented 16 | - [ ] Plugin configuration parameters 17 | 18 | ## Requirements 19 | 20 | * NetBox 4.1 or higher 21 | * Python 3.10 or higher 22 | 23 | ## Installation & Configuration 24 | 25 | For general knowledge about netbox plugin installation please refer to the official guide: [Using Plugins - NetBox Documentation](https://netbox.readthedocs.io/en/stable/plugins/) 26 | 27 | Install NetBox Webhook Receiver: 28 | ```bash 29 | $ source /opt/netbox/venv/bin/activate 30 | (venv) $ pip install netbox-plugin-webhook-receiver 31 | ``` 32 | 33 | To register this plugin in NetBox, add _netbox_webhook_receiver_ to the config file. `~/netbox/configuration.py` 34 | 35 | ```python 36 | PLUGINS = [ 37 | "netbox_webhook_receiver", 38 | ] 39 | ``` 40 | 41 | Please remember that this plugin introduces new database models, therefore you must run the provided database schema migrations: 42 | ```bash 43 | $ source /opt/netbox/venv/bin/activate 44 | (venv) $ cd /opt/netbox/netbox/ 45 | (venv) $ python3 manage.py migrate 46 | ``` 47 | 48 | ## Screenshots 49 | 50 | ![Webhook Receivers](/docs/images/webhook_receivers.png) 51 | ![Webhook Receiver](/docs/images/webhook_receiver.png) 52 | ![Webhook Receiver Group](/docs/images/webhook_receiver_group.png) 53 | -------------------------------------------------------------------------------- /docs/images/webhook_receiver.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LuPo/netbox-plugin-webhook-receiver/619a6630e04239715f33051dcd4ad6b62bd25e33/docs/images/webhook_receiver.png -------------------------------------------------------------------------------- /docs/images/webhook_receiver_group.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LuPo/netbox-plugin-webhook-receiver/619a6630e04239715f33051dcd4ad6b62bd25e33/docs/images/webhook_receiver_group.png -------------------------------------------------------------------------------- /docs/images/webhook_receivers.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LuPo/netbox-plugin-webhook-receiver/619a6630e04239715f33051dcd4ad6b62bd25e33/docs/images/webhook_receivers.png -------------------------------------------------------------------------------- /netbox_webhook_receiver/__init__.py: -------------------------------------------------------------------------------- 1 | from netbox.plugins import PluginConfig 2 | 3 | __version__ = "0.2.0" 4 | 5 | 6 | class NetBoxWebhookReceiverConfig(PluginConfig): 7 | name = "netbox_webhook_receiver" 8 | verbose_name = "NetBox Webhook Receiver" 9 | description = "Manage webhook receivers and queues related actions in NetBox" 10 | version = __version__ 11 | author = "Łukasz Polański" 12 | author_email = "wookasz@gmail.com" 13 | required_settings = [] 14 | default_settings = {} 15 | base_url = "webhook-receiver" 16 | 17 | 18 | config = NetBoxWebhookReceiverConfig 19 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LuPo/netbox-plugin-webhook-receiver/619a6630e04239715f33051dcd4ad6b62bd25e33/netbox_webhook_receiver/api/__init__.py -------------------------------------------------------------------------------- /netbox_webhook_receiver/api/serializers.py: -------------------------------------------------------------------------------- 1 | from rest_framework import serializers 2 | 3 | from netbox.api.serializers import NetBoxModelSerializer 4 | from ..models import WebhookReceiver, WebhookReceiverGroup 5 | 6 | 7 | class WebhookReceiverSerializer(NetBoxModelSerializer): 8 | url = serializers.HyperlinkedIdentityField( 9 | view_name="plugins-api:netbox_webhook_receiver-api:webhookreceiver-detail" 10 | ) 11 | 12 | class Meta: 13 | model = WebhookReceiver 14 | fields = ( 15 | "custom_fields", 16 | "created", 17 | "datasource", 18 | "last_updated", 19 | "id", 20 | "display", 21 | "name", 22 | "receiver_group", 23 | "store_payload", 24 | "tags", 25 | "auth_header", 26 | "auth_method", 27 | "hash_algorithm", 28 | "url", 29 | "uuid", 30 | ) 31 | 32 | 33 | class WebhookReceiverGroupSerializer(NetBoxModelSerializer): 34 | url = serializers.HyperlinkedIdentityField( 35 | view_name="plugins-api:netbox_webhook_receiver-api:webhookreceivergroup-detail" 36 | ) 37 | receivers_count = serializers.IntegerField(read_only=True) 38 | 39 | class Meta: 40 | model = WebhookReceiverGroup 41 | fields = ( 42 | "created", 43 | "last_updated", 44 | "id", 45 | "display", 46 | "name", 47 | "tags", 48 | "description", 49 | "receivers_count", 50 | "url", 51 | ) 52 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/api/urls.py: -------------------------------------------------------------------------------- 1 | from netbox.api.routers import NetBoxRouter 2 | from . import views 3 | 4 | app_name = "netbox_webhook_receiver" 5 | 6 | router = NetBoxRouter() 7 | router.register("receivers", views.WebhookReceiverViewSet) 8 | router.register("groups", views.WebhookReceiverGroupViewSet) 9 | 10 | urlpatterns = router.urls 11 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/api/views.py: -------------------------------------------------------------------------------- 1 | from netbox.api.viewsets import NetBoxModelViewSet 2 | from django.db.models import Count 3 | 4 | from .. import models # , filtersets 5 | from .serializers import WebhookReceiverSerializer, WebhookReceiverGroupSerializer 6 | 7 | 8 | class WebhookReceiverViewSet(NetBoxModelViewSet): 9 | queryset = models.WebhookReceiver.objects.prefetch_related("tags") 10 | serializer_class = WebhookReceiverSerializer 11 | 12 | 13 | class WebhookReceiverGroupViewSet(NetBoxModelViewSet): 14 | queryset = models.WebhookReceiverGroup.objects.prefetch_related("tags").annotate( 15 | rule_count=Count("receivers") 16 | ) 17 | serializer_class = WebhookReceiverGroupSerializer 18 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/choices.py: -------------------------------------------------------------------------------- 1 | from utilities.choices import ChoiceSet 2 | 3 | 4 | class WebhookAuthMethodChoices(ChoiceSet): 5 | TOKEN = "token" 6 | SIGNATURE_VERIFICATION = "signature_verification" 7 | 8 | CHOICES = [ 9 | (TOKEN, "Token"), 10 | (SIGNATURE_VERIFICATION, "Signature Verification"), 11 | ] 12 | 13 | 14 | class HashingAlgorithmChoices(ChoiceSet): 15 | SHA_256 = "sha256" 16 | SHA_512 = "sha512" 17 | 18 | CHOICES = ( 19 | (SHA_256, "SHA-256"), 20 | (SHA_512, "SHA-512"), 21 | ) 22 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/filtersets.py: -------------------------------------------------------------------------------- 1 | from netbox.filtersets import NetBoxModelFilterSet 2 | from .models import WebhookReceiver, WebhookReceiverGroup 3 | 4 | 5 | class WebhookReceiverFilterSet(NetBoxModelFilterSet): 6 | class Meta: 7 | model = WebhookReceiver 8 | fields = ( 9 | "uuid", 10 | "store_payload", 11 | "receiver_group", 12 | "hash_algorithm", 13 | "auth_method", 14 | ) 15 | 16 | def search(self, queryset, name, value): 17 | return queryset.filter(description__icontains=value) 18 | 19 | 20 | class WebhookReceiverGroupFilterSet(NetBoxModelFilterSet): 21 | class Meta: 22 | model = WebhookReceiverGroup 23 | fields = ("name",) 24 | 25 | def search(self, queryset, name, value): 26 | return queryset.filter(description__icontains=value) 27 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/forms.py: -------------------------------------------------------------------------------- 1 | from django import forms 2 | from netbox.forms import NetBoxModelForm, NetBoxModelFilterSetForm 3 | from utilities.forms.fields import CommentField 4 | from utilities.forms.rendering import FieldSet 5 | from utilities.forms.utils import get_field_value 6 | from utilities.forms.widgets import HTMXSelect 7 | from .choices import WebhookAuthMethodChoices 8 | from .models import WebhookReceiver, WebhookReceiverGroup 9 | 10 | 11 | class WebhookReceiverForm(NetBoxModelForm): 12 | comments = CommentField() 13 | 14 | fieldsets = ( 15 | FieldSet( 16 | "name", 17 | "receiver_group", 18 | "description", 19 | "uuid", 20 | "store_payload", 21 | name="Webhook definition", 22 | ), 23 | FieldSet( 24 | "auth_method", 25 | "auth_header", 26 | "secret_key", 27 | "token", 28 | "hash_algorithm", 29 | name="Authentication", 30 | ), 31 | FieldSet("datasource", name="Trigger action"), 32 | FieldSet("tags", name="Extra"), 33 | ) 34 | 35 | # Thanks to Dillon Henschen for his netbox pull #12675 36 | def __init__(self, *args, **kwargs): 37 | super().__init__(*args, **kwargs) 38 | 39 | auth_method = get_field_value(self, "auth_method") 40 | 41 | if auth_method != WebhookAuthMethodChoices.TOKEN: 42 | del self.fields["token"] 43 | self.fields[ 44 | "auth_header" 45 | ].help_text = "Custom Header option carying \ 46 | payload signature (eg. 'X-Hub-Signature')." 47 | if auth_method != WebhookAuthMethodChoices.SIGNATURE_VERIFICATION: 48 | del self.fields["hash_algorithm"] 49 | del self.fields["secret_key"] 50 | 51 | class Meta: 52 | model = WebhookReceiver 53 | fields = ( 54 | "name", 55 | "receiver_group", 56 | "description", 57 | "store_payload", 58 | "auth_method", 59 | "auth_header", 60 | "hash_algorithm", 61 | "secret_key", 62 | "token", 63 | "uuid", 64 | "datasource", 65 | "tags", 66 | "comments", 67 | ) 68 | widgets = { 69 | "auth_method": HTMXSelect(), 70 | } 71 | 72 | def clean(self): 73 | super().clean() 74 | 75 | auth_method = self.cleaned_data.get("auth_method") 76 | 77 | if auth_method != WebhookAuthMethodChoices.TOKEN: 78 | self.cleaned_data["token"] = None 79 | 80 | if auth_method != WebhookAuthMethodChoices.SIGNATURE_VERIFICATION: 81 | self.cleaned_data["secret_key"] = None 82 | 83 | 84 | class WebhookReceiverFilterForm(NetBoxModelFilterSetForm): 85 | model = WebhookReceiver 86 | # tags = TagFilterField( 87 | # required=False 88 | # ) 89 | uuid = forms.CharField(required=False) 90 | receiver_group = forms.ModelMultipleChoiceField( 91 | queryset=WebhookReceiverGroup.objects.all(), required=False 92 | ) 93 | 94 | 95 | class WebhookReceiverGroupForm(NetBoxModelForm): 96 | comments = CommentField() 97 | 98 | class Meta: 99 | model = WebhookReceiverGroup 100 | fields = ( 101 | "name", 102 | "description", 103 | "tags", 104 | "comments", 105 | ) 106 | 107 | 108 | class WebhookReceiverGroupFilterForm(NetBoxModelFilterSetForm): 109 | model = WebhookReceiverGroup 110 | # tags = TagFilterField( 111 | # required=False 112 | # ) 113 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/migrations/0001_initial.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-07-03 12:00 2 | 3 | from django.db import migrations, models 4 | import django.db.models.deletion 5 | import taggit.managers 6 | import utilities.json 7 | import uuid 8 | 9 | 10 | class Migration(migrations.Migration): 11 | initial = True 12 | 13 | dependencies = [ 14 | ("core", "0005_job_created_auto_now"), 15 | ("extras", "0092_delete_jobresult"), 16 | ] 17 | 18 | operations = [ 19 | migrations.CreateModel( 20 | name="WebhookMessage", 21 | fields=[ 22 | ( 23 | "id", 24 | models.BigAutoField( 25 | auto_created=True, primary_key=True, serialize=False 26 | ), 27 | ), 28 | ("payload", models.JSONField(default=None, null=True)), 29 | ("received_at", models.DateTimeField()), 30 | ], 31 | options={ 32 | "ordering": ["received_at"], 33 | }, 34 | ), 35 | migrations.CreateModel( 36 | name="WebhookReceiverGroup", 37 | fields=[ 38 | ( 39 | "id", 40 | models.BigAutoField( 41 | auto_created=True, primary_key=True, serialize=False 42 | ), 43 | ), 44 | ("created", models.DateTimeField(auto_now_add=True, null=True)), 45 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 46 | ( 47 | "custom_field_data", 48 | models.JSONField( 49 | blank=True, 50 | default=dict, 51 | encoder=utilities.json.CustomFieldJSONEncoder, 52 | ), 53 | ), 54 | ("comments", models.TextField(blank=True)), 55 | ("description", models.CharField(blank=True, max_length=500)), 56 | ("name", models.CharField(max_length=50)), 57 | ( 58 | "tags", 59 | taggit.managers.TaggableManager( 60 | through="extras.TaggedItem", to="extras.Tag" 61 | ), 62 | ), 63 | ], 64 | options={ 65 | "ordering": ("name",), 66 | }, 67 | ), 68 | migrations.CreateModel( 69 | name="WebhookReceiver", 70 | fields=[ 71 | ( 72 | "id", 73 | models.BigAutoField( 74 | auto_created=True, primary_key=True, serialize=False 75 | ), 76 | ), 77 | ("created", models.DateTimeField(auto_now_add=True, null=True)), 78 | ("last_updated", models.DateTimeField(auto_now=True, null=True)), 79 | ( 80 | "custom_field_data", 81 | models.JSONField( 82 | blank=True, 83 | default=dict, 84 | encoder=utilities.json.CustomFieldJSONEncoder, 85 | ), 86 | ), 87 | ("comments", models.TextField(blank=True)), 88 | ("description", models.CharField(blank=True, max_length=500)), 89 | ("name", models.CharField(max_length=50)), 90 | ("token", models.CharField(blank=True, max_length=50, null=True)), 91 | ( 92 | "auth_header", 93 | models.CharField(default="X-Gitlab-Token", max_length=50), 94 | ), 95 | ("auth_method", models.CharField(default="token", max_length=50)), 96 | ("secret_key", models.CharField(blank=True, max_length=50, null=True)), 97 | ("store_payload", models.BooleanField(default=True)), 98 | ("uuid", models.UUIDField(default=uuid.uuid4)), 99 | ( 100 | "datasource", 101 | models.ForeignKey( 102 | blank=True, 103 | null=True, 104 | on_delete=django.db.models.deletion.CASCADE, 105 | to="core.datasource", 106 | ), 107 | ), 108 | ( 109 | "receiver_group", 110 | models.ForeignKey( 111 | blank=True, 112 | null=True, 113 | on_delete=django.db.models.deletion.PROTECT, 114 | related_name="receivers", 115 | to="netbox_webhook_receiver.webhookreceivergroup", 116 | ), 117 | ), 118 | ( 119 | "tags", 120 | taggit.managers.TaggableManager( 121 | through="extras.TaggedItem", to="extras.Tag" 122 | ), 123 | ), 124 | ], 125 | options={ 126 | "ordering": ("name", "receiver_group"), 127 | }, 128 | ), 129 | migrations.AddIndex( 130 | model_name="webhookmessage", 131 | index=models.Index( 132 | fields=["received_at"], name="netbox_webh_receive_6a2187_idx" 133 | ), 134 | ), 135 | ] 136 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/migrations/0002_webhookreceiver_hash_algorithm.py: -------------------------------------------------------------------------------- 1 | # Generated by Django 4.1.9 on 2023-07-04 11:28 2 | 3 | from django.db import migrations, models 4 | 5 | 6 | class Migration(migrations.Migration): 7 | dependencies = [ 8 | ("netbox_webhook_receiver", "0001_initial"), 9 | ] 10 | 11 | operations = [ 12 | migrations.AddField( 13 | model_name="webhookreceiver", 14 | name="hash_algorithm", 15 | field=models.CharField(blank=True, max_length=50, null=True), 16 | ), 17 | ] 18 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/migrations/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/LuPo/netbox-plugin-webhook-receiver/619a6630e04239715f33051dcd4ad6b62bd25e33/netbox_webhook_receiver/migrations/__init__.py -------------------------------------------------------------------------------- /netbox_webhook_receiver/models.py: -------------------------------------------------------------------------------- 1 | from .choices import WebhookAuthMethodChoices, HashingAlgorithmChoices 2 | from core.models import DataSource 3 | from django.db import models 4 | from django.urls import reverse 5 | from netbox.models import NetBoxModel 6 | import uuid 7 | 8 | 9 | class WebhookMessage(models.Model): 10 | payload = models.JSONField(default=None, null=True) 11 | received_at = models.DateTimeField(help_text="When we received the event.") 12 | 13 | class Meta: 14 | ordering = ["received_at"] 15 | indexes = [ 16 | models.Index(fields=["received_at"]), 17 | ] 18 | 19 | def __str__(self): 20 | return self.received_at 21 | 22 | 23 | class WebhookReceiverGroup(NetBoxModel): 24 | comments = models.TextField(blank=True) 25 | description = models.CharField(max_length=500, blank=True) 26 | name = models.CharField( 27 | help_text="Webhook receiver group", max_length=50, null=False 28 | ) 29 | 30 | class Meta: 31 | ordering = ("name",) 32 | 33 | def __str__(self): 34 | return self.name 35 | 36 | def get_absolute_url(self): 37 | return reverse( 38 | "plugins:netbox_webhook_receiver:webhookreceivergroup", args=[self.pk] 39 | ) 40 | 41 | 42 | class WebhookReceiver(NetBoxModel): 43 | comments = models.TextField(blank=True) 44 | datasource = models.ForeignKey( 45 | help_text="Incomming webhook triggers update of selected datasource", 46 | to=DataSource, 47 | on_delete=models.CASCADE, 48 | blank=True, 49 | null=True, 50 | ) 51 | description = models.CharField(max_length=500, blank=True) 52 | name = models.CharField(max_length=50, null=False) 53 | receiver_group = models.ForeignKey( 54 | to=WebhookReceiverGroup, 55 | on_delete=models.PROTECT, 56 | related_name="receivers", 57 | blank=True, 58 | null=True, 59 | ) 60 | token = models.CharField( 61 | help_text="Authentication is mandatory. Custom field Token", 62 | max_length=50, 63 | null=True, 64 | blank=True, 65 | ) 66 | 67 | auth_header = models.CharField( 68 | help_text="Custom Header option carying authentication token", 69 | max_length=50, 70 | null=False, 71 | default="X-Gitlab-Token", 72 | ) 73 | auth_method = models.CharField( 74 | help_text="Webhook authentication method", 75 | max_length=50, 76 | choices=WebhookAuthMethodChoices, 77 | default=WebhookAuthMethodChoices.TOKEN, 78 | null=False, 79 | ) 80 | secret_key = models.CharField( 81 | help_text="Authentication is mandatory. \ 82 | Secret key for HMAC hex digest of the payload body", 83 | max_length=50, 84 | null=True, 85 | blank=True, 86 | ) 87 | hash_algorithm = models.CharField( 88 | help_text="Hashing algorithm for message authentication signature. \ 89 | If not provided sha512 will be used", 90 | max_length=50, 91 | choices=HashingAlgorithmChoices, 92 | null=True, 93 | blank=True, 94 | ) 95 | store_payload = models.BooleanField( 96 | help_text="Store payload of incomming webhooks", default=True 97 | ) 98 | uuid = models.UUIDField(default=uuid.uuid4) 99 | 100 | class Meta: 101 | ordering = ( 102 | "name", 103 | "receiver_group", 104 | ) 105 | 106 | def __str__(self): 107 | return self.name 108 | 109 | def get_absolute_url(self): 110 | return reverse( 111 | "plugins:netbox_webhook_receiver:webhookreceiver", args=[self.pk] 112 | ) 113 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/navigation.py: -------------------------------------------------------------------------------- 1 | from netbox.plugins import PluginMenuButton, PluginMenuItem 2 | 3 | webhook_receiver_buttons = [ 4 | PluginMenuButton( 5 | link="plugins:netbox_webhook_receiver:webhookreceiver_add", 6 | title="Add", 7 | icon_class="mdi mdi-plus-thick", 8 | ) 9 | ] 10 | 11 | webhook_receiver_group_buttons = [ 12 | PluginMenuButton( 13 | link="plugins:netbox_webhook_receiver:webhookreceivergroup_add", 14 | title="Add", 15 | icon_class="mdi mdi-plus-thick", 16 | ) 17 | ] 18 | 19 | menu_items = ( 20 | PluginMenuItem( 21 | link="plugins:netbox_webhook_receiver:webhookreceivergroup_list", 22 | link_text="Webhook Receiver Groups", 23 | buttons=webhook_receiver_group_buttons, 24 | ), 25 | PluginMenuItem( 26 | link="plugins:netbox_webhook_receiver:webhookreceiver_list", 27 | link_text="Webhook Receivers", 28 | buttons=webhook_receiver_buttons, 29 | ), 30 | ) 31 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/tables.py: -------------------------------------------------------------------------------- 1 | import django_tables2 as tables 2 | 3 | from netbox.tables import NetBoxTable 4 | from .models import WebhookReceiver, WebhookReceiverGroup 5 | 6 | 7 | class WebhookReceiverTable(NetBoxTable): 8 | name = tables.Column(linkify=True) 9 | receiver_group = tables.Column(linkify=True) 10 | 11 | class Meta(NetBoxTable.Meta): 12 | model = WebhookReceiver 13 | fields = ( 14 | "pk", 15 | "comments", 16 | "name", 17 | "datasource", 18 | "description", 19 | "receiver_group", 20 | "store_payload", 21 | "auth_header", 22 | "auth_method", 23 | "hash_algorithm", 24 | "secret_key", 25 | "token", 26 | "uuid", 27 | ) 28 | default_columns = ("name", "receiver_group", "description", "uuid") 29 | 30 | 31 | class WebhookReceiverGroupTable(NetBoxTable): 32 | name = tables.Column(linkify=True) 33 | receivers_count = tables.Column() 34 | 35 | class Meta(NetBoxTable.Meta): 36 | model = WebhookReceiverGroup 37 | fields = ( 38 | "pk", 39 | "comments", 40 | "name", 41 | "description", 42 | "receivers_count", 43 | ) 44 | default_columns = ("name", "description", "receivers_count") 45 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/templates/netbox_webhook_receiver/webhookreceiver.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
Webhook Receiver
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
Name{{ object.name }}
Description{{ object.description }}
Receiver group 21 | {{ object.receiver_group }} 22 |
UUID{{ object.uuid }}
Auth method{{ object.auth_method }}
Auth header{{ object.auth_header }}
Token{{ object.token|placeholder }}
Hash algotithm{{ object.hash_algorithm|placeholder }}
Secret Key{{ object.secret_key|placeholder }}
49 |
50 | {% include 'inc/panels/custom_fields.html' %} 51 |
52 |
53 |
54 |
Webhook actions
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 65 | 66 | 67 | 68 | 69 | 70 |
Store payload{{ object.store_payload|placeholder }}
Update datasource 63 | {{ object.datasource|placeholder }} 64 |
Run Custom Script{{ object.customscript|placeholder }}
71 |
72 | 73 | 74 | {% include 'inc/panels/tags.html' %} 75 | {% include 'inc/panels/comments.html' %} 76 |
77 |
78 |
79 |
80 |
81 |
Webhook Receiver URL
82 | 83 | 84 | 85 | 90 | 91 |
{{request.headers.referer | split:"/" | slice:"5" | join:"/" }}/webhooks/{{ object.uuid}}/ 86 |
87 | {% copy_content "webhook_endpoint" %} 88 |
89 |
92 |
93 |
94 |
95 | {% endblock content %} 96 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/templates/netbox_webhook_receiver/webhookreceivergroup.html: -------------------------------------------------------------------------------- 1 | {% extends 'generic/object.html' %} 2 | {% load render_table from django_tables2 %} 3 | 4 | {% block content %} 5 |
6 |
7 |
8 |
Webhook Receiver Group
9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 |
Name{{ object.name }}
Description{{ object.description }}
Tenant{{ object.tenant }}
Related receivers{{ object.receivers.count }}
27 |
28 | {% include 'inc/panels/custom_fields.html' %} 29 |
30 |
31 | {% include 'inc/panels/tags.html' %} 32 | {% include 'inc/panels/comments.html' %} 33 |
34 |
35 |
36 |
37 |
38 |
Receivers
39 | {% render_table receivers_table %} 40 |
41 |
42 |
43 | {% endblock content %} 44 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/urls.py: -------------------------------------------------------------------------------- 1 | # ruff: noqa: E501 2 | # fmt: off 3 | from netbox.views.generic import ObjectChangeLogView 4 | from django.urls import path 5 | from . import models, views 6 | from netbox_webhook_receiver.views import incomming_webhook_request 7 | 8 | urlpatterns = ( 9 | # Receive Webhook Messages 10 | path("webhooks//", incomming_webhook_request), 11 | # Webhook Receiver 12 | path("receivers/", views.WebhookReceiverListView.as_view(), name="webhookreceiver_list",), 13 | path("receivers/add/", views.WebhookReceiverEditView.as_view(), name="webhookreceiver_add",), 14 | path("receivers//", views.WebhookReceiverView.as_view(), name="webhookreceiver",), 15 | path("receivers//edit/", views.WebhookReceiverEditView.as_view(), name="webhookreceiver_edit",), 16 | path("receivers//delete/", views.WebhookReceiverDeleteView.as_view(), name="webhookreceiver_delete",), 17 | path("receivers//changelog/", ObjectChangeLogView.as_view(), 18 | name="webhookreceiver_changelog", 19 | kwargs={"model": models.WebhookReceiver}, 20 | ), 21 | # Webhook Receiver Group 22 | path("groups/", views.WebhookReceiverGroupListView.as_view(), name="webhookreceivergroup_list",), 23 | path("groups/add/", views.WebhookReceiverGroupEditView.as_view(), name="webhookreceivergroup_add",), 24 | path("groups//", views.WebhookReceiverGroupView.as_view(), name="webhookreceivergroup",), 25 | path("groups//edit/", views.WebhookReceiverGroupEditView.as_view(), name="webhookreceivergroup_edit",), 26 | path("groups//delete/", views.WebhookReceiverGroupDeleteView.as_view(), name="webhookreceivergroup_delete",), 27 | path("groups//changelog/", ObjectChangeLogView.as_view(), 28 | name="webhookreceivergroup_changelog", 29 | kwargs={"model": models.WebhookReceiverGroup}, 30 | ), 31 | ) 32 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .views import * # noqa : F403 2 | from .webhookreceiver import * # noqa : F403 3 | from .webhookreceivergroup import * # noqa : F403 4 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/views/views.py: -------------------------------------------------------------------------------- 1 | import datetime as dt 2 | import json 3 | import logging 4 | 5 | from django.db.transaction import atomic, non_atomic_requests 6 | from django.http import ( 7 | HttpResponse, 8 | HttpResponseForbidden, 9 | HttpResponseNotFound, 10 | ) 11 | from django.utils import timezone 12 | from django.views.decorators.csrf import csrf_exempt 13 | from django.views.decorators.http import require_POST 14 | from netbox_webhook_receiver.models import WebhookMessage, WebhookReceiver 15 | from secrets import compare_digest 16 | from core.jobs import SyncDataSourceJob 17 | from users.models import User 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | 22 | @csrf_exempt 23 | @require_POST 24 | @non_atomic_requests 25 | def incomming_webhook_request(request, **kwargs): 26 | """ 27 | Validates incomming webhook path and custom header authentication. 28 | Calls the webhook processing function. 29 | """ 30 | try: 31 | receiver = WebhookReceiver.objects.get(uuid=kwargs["random_path"]) 32 | except WebhookReceiver.DoesNotExist: 33 | return HttpResponseNotFound( 34 | f"Provided path selector ({kwargs['random_path']}) is not correct" 35 | ) 36 | 37 | # Check if defined Authorisation header is present in the request 38 | if receiver.auth_header not in request.headers: 39 | return HttpResponseForbidden( 40 | f"Did not get authentication header {receiver.auth_header} in the request" 41 | ) 42 | 43 | # Webhook authentication 44 | verified = authenticate_request(request, receiver) 45 | if not verified: 46 | return HttpResponseForbidden( 47 | f"Incorrect {'token' if receiver.token else 'signature'} \ 48 | in {receiver.auth_header} header.", 49 | content_type="text/plain", 50 | ) 51 | 52 | # Retain webhook payload for any future use case. 53 | if receiver.store_payload: 54 | WebhookMessage.objects.filter( 55 | received_at__lte=timezone.now() - dt.timedelta(days=2) 56 | ).delete() 57 | payload = json.loads(request.body) 58 | WebhookMessage.objects.create( 59 | received_at=timezone.now(), 60 | payload=payload, 61 | ) 62 | 63 | result = "No action has been taken by webhook receiver" 64 | # So far datasource sync is the only action of the incomming webhook 65 | if receiver.datasource: 66 | # Prevent queuing multiple syncronizzation requestes 67 | if job := receiver.datasource.jobs.first(): 68 | if job.status == "pending": 69 | return HttpResponseForbidden( 70 | "There are already pending jobs in the queue", 71 | content_type="text/plain", 72 | ) 73 | 74 | result = process_webhook_sync_datasource(receiver) 75 | 76 | logger.info(result) 77 | return HttpResponse( 78 | result, 79 | content_type="text/plain", 80 | ) 81 | 82 | 83 | @atomic 84 | def process_webhook_sync_datasource(receiver): 85 | SyncDataSourceJob.enqueue( 86 | instance=receiver.datasource, user=DefaultUserRequest().user 87 | ) 88 | 89 | return f"Synchronizing Data Source: {receiver.datasource.name}" 90 | 91 | 92 | def authenticate_request(request, receiver) -> bool: 93 | """ 94 | Auth token/signature header name is defined on individual webhook receiver. 95 | """ 96 | # Custom header auth method 97 | if receiver.token: 98 | request_token = ascii(request.headers.get(receiver.auth_header, "")) 99 | configured_token = ascii(receiver.token) 100 | return compare_digest(request_token, configured_token) 101 | 102 | # Signature verification auth method 103 | elif receiver.secret_key: 104 | import hashlib 105 | import hmac 106 | 107 | hmac_header = request.headers.get(receiver.auth_header, "").removeprefix( 108 | "sha256=" 109 | ) 110 | hash_algorithm = receiver.hash_algorithm or "sha512" 111 | 112 | # Calculate hexadecimal HMAC digest 113 | hmac_digest = hmac.new( 114 | key=receiver.secret_key.encode("utf-8"), 115 | msg=request.body, 116 | digestmod=getattr(hashlib, hash_algorithm), 117 | ).hexdigest() 118 | 119 | return hmac.compare_digest( 120 | hmac_digest.encode("utf-8"), hmac_header.encode("utf-8") 121 | ) 122 | 123 | else: 124 | return False 125 | 126 | 127 | class DefaultUserRequest: 128 | """ 129 | Netbox Job model requres valid user object when queueing datasource sync. 130 | Hardcoding default admin user 131 | """ 132 | 133 | def __init__(self, userid=1): 134 | self.user = User.objects.get(id=userid) 135 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/views/webhookreceiver.py: -------------------------------------------------------------------------------- 1 | from netbox.views import generic 2 | from .. import filtersets, forms, models, tables 3 | 4 | 5 | class WebhookReceiverView(generic.ObjectView): 6 | queryset = models.WebhookReceiver.objects.all() 7 | # template_name = "netbox_webhook_receiver/webhookreceiver.html" 8 | 9 | 10 | class WebhookReceiverListView(generic.ObjectListView): 11 | queryset = models.WebhookReceiver.objects.all() 12 | table = tables.WebhookReceiverTable 13 | filterset = filtersets.WebhookReceiverFilterSet 14 | filterset_form = forms.WebhookReceiverFilterForm 15 | 16 | 17 | class WebhookReceiverEditView(generic.ObjectEditView): 18 | queryset = models.WebhookReceiver.objects.all() 19 | form = forms.WebhookReceiverForm 20 | 21 | 22 | class WebhookReceiverDeleteView(generic.ObjectDeleteView): 23 | queryset = models.WebhookReceiver.objects.all() 24 | -------------------------------------------------------------------------------- /netbox_webhook_receiver/views/webhookreceivergroup.py: -------------------------------------------------------------------------------- 1 | from django.db.models import Count 2 | from netbox.views import generic 3 | from .. import filtersets, forms, models, tables 4 | 5 | 6 | class WebhookReceiverGroupView(generic.ObjectView): 7 | queryset = models.WebhookReceiverGroup.objects.all() 8 | 9 | def get_extra_context(self, request, instance): 10 | table = tables.WebhookReceiverTable(instance.receivers.all()) 11 | table.configure(request) 12 | return { 13 | "receivers_table": table, 14 | } 15 | 16 | 17 | class WebhookReceiverGroupListView(generic.ObjectListView): 18 | queryset = models.WebhookReceiverGroup.objects.all() 19 | table = tables.WebhookReceiverGroupTable 20 | filterset = filtersets.WebhookReceiverGroupFilterSet 21 | filterset_form = forms.WebhookReceiverGroupFilterForm 22 | queryset = models.WebhookReceiverGroup.objects.annotate( 23 | receivers_count=Count("receivers") 24 | ) 25 | 26 | 27 | class WebhookReceiverGroupEditView(generic.ObjectEditView): 28 | queryset = models.WebhookReceiverGroup.objects.all() 29 | form = forms.WebhookReceiverGroupForm 30 | 31 | 32 | class WebhookReceiverGroupDeleteView(generic.ObjectDeleteView): 33 | queryset = models.WebhookReceiverGroup.objects.all() 34 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "netbox-plugin-webhook-receiver" 3 | version = "0.5.0" 4 | description = "NetBox Webhook is a plugin for managing webhook receivers in NetBox." 5 | authors = ["Łukasz Polański "] 6 | homepage = "https://github.com/LuPo/netbox-plugin-webhook-receiver" 7 | repository = "https://github.com/LuPo/netbox-plugin-webhook-receiver" 8 | license = "MIT" 9 | readme = "README.md" 10 | packages = [{include = "netbox_webhook_receiver"}] 11 | exclude = ["netbox_webhook_receiver/tests/*"] 12 | keywords = ["netbox", "netbox-plugin", "webhook"] 13 | 14 | [tool.poetry.dependencies] 15 | python = "^3.10" 16 | 17 | [tool.poetry.group.dev.dependencies] 18 | pytest = ">=8.1.0" 19 | black = "^24.0.0" 20 | 21 | [build-system] 22 | requires = ["poetry-core"] 23 | build-backend = "poetry.core.masonry.api" 24 | --------------------------------------------------------------------------------