diff --git a/apps/common/api/__init__.py b/apps/common/api/__init__.py index 15797eb69..8a4cb89d2 100644 --- a/apps/common/api/__init__.py +++ b/apps/common/api/__init__.py @@ -5,3 +5,4 @@ from .mixin import * from .patch import * from .permission import * from .serializer import * +from .webhook import * diff --git a/apps/common/api/webhook.py b/apps/common/api/webhook.py new file mode 100644 index 000000000..fdf18d4b2 --- /dev/null +++ b/apps/common/api/webhook.py @@ -0,0 +1,64 @@ +import hashlib +import hmac + +from django.conf import settings +from rest_framework import status +from rest_framework.permissions import AllowAny +from rest_framework.response import Response +from rest_framework.views import APIView + +from common.signals import webhook_signal + + +class WebhookApi(APIView): + """ + data: + { + "event": "license_updated", + "payload": { + } + """ + authentication_classes = () + permission_classes = (AllowAny,) + + signature_header = 'HTTP_X_WEBHOOK_SIGNATURE' + + @staticmethod + def _normalize_signature(signature): + signature = str(signature or '').strip() + if signature.startswith('sha256='): + return signature.split('=', 1)[1] + return signature + + def _is_valid_signature(self, body, signature): + token = getattr(settings, 'WEBHOOK_TOKEN', '') + if not token: + return False + + expected = hmac.new( + token.encode('utf-8'), + msg=body, + digestmod=hashlib.sha256 + ).hexdigest() + return hmac.compare_digest(expected, self._normalize_signature(signature)) + + def post(self, request, *args, **kwargs): + signature = request.META.get(self.signature_header, '') + body = request.body or b'' + data = request.data + event = data.get('event', '') + payload = data.get('payload', {}) + + if not signature: + return Response({'detail': 'Missing X-Webhook-Signature'}, status=status.HTTP_400_BAD_REQUEST) + + if not self._is_valid_signature(body, signature): + return Response({'detail': 'Invalid webhook signature'}, status=status.HTTP_403_FORBIDDEN) + + webhook_signal.send( + sender=self.__class__, + event=event, + payload=payload, + headers=request.headers, + ) + return Response({'detail': 'Webhook accepted'}, status=status.HTTP_202_ACCEPTED) diff --git a/apps/common/signal_handlers.py b/apps/common/signal_handlers.py index a3fe9c53d..b02e9fec1 100644 --- a/apps/common/signal_handlers.py +++ b/apps/common/signal_handlers.py @@ -12,7 +12,7 @@ from django.dispatch import receiver from jumpserver.utils import get_current_request from .local import thread_local -from .signals import django_ready +from .signals import django_ready, webhook_signal from .utils import get_logger pattern = re.compile(r'FROM `(\w+)`') diff --git a/apps/common/signals.py b/apps/common/signals.py index de8a84139..e34576e09 100644 --- a/apps/common/signals.py +++ b/apps/common/signals.py @@ -4,3 +4,4 @@ from django.dispatch import Signal django_ready = Signal() +webhook_signal = Signal() diff --git a/apps/common/urls/api_urls.py b/apps/common/urls/api_urls.py index 923a5f54b..a70836494 100644 --- a/apps/common/urls/api_urls.py +++ b/apps/common/urls/api_urls.py @@ -2,6 +2,7 @@ # from django.urls import path +from django.conf import settings from .. import api @@ -11,3 +12,6 @@ urlpatterns = [ path('resources/cache/', api.ResourcesIDCacheApi.as_view(), name='resources-cache'), path('countries/', api.CountryListApi.as_view(), name='resources-cache'), ] + +if settings.WEBHOOK_ENABLED: + urlpatterns.append(path('webhook/', api.WebhookApi.as_view(), name='webhooks')) \ No newline at end of file diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index c2a62181b..8d0648fdf 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -751,6 +751,10 @@ class Config(dict): 'JDMC_ENABLED': False, 'JDMC_SOCK_PATH': '', 'JDMC_LICENSE_PUBLIC_KEY_PATH': '', + + # WEBHOOK + 'WEBHOOK_ENABLED': False, + 'WEBHOOK_TOKEN': '', } old_config_map = { diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 6f52006d2..9f970c1b6 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -281,4 +281,8 @@ if Path(VENDOR_TEMPLATES_DIR).is_dir(): JDMC_ENABLED = CONFIG.JDMC_ENABLED JDMC_SOCK_PATH = CONFIG.JDMC_SOCK_PATH JDMC_BASE_URL = f"http+unix://{quote(JDMC_SOCK_PATH, safe='')}" -JDMC_LICENSE_PUBLIC_KEY_PATH = CONFIG.JDMC_LICENSE_PUBLIC_KEY_PATH \ No newline at end of file +JDMC_LICENSE_PUBLIC_KEY_PATH = CONFIG.JDMC_LICENSE_PUBLIC_KEY_PATH + +# WebHook +WEBHOOK_ENABLED = CONFIG.WEBHOOK_ENABLED +WEBHOOK_TOKEN = CONFIG.WEBHOOK_TOKEN