diff --git a/.github/workflows/build-base-image.yml b/.github/workflows/build-base-image.yml index 3f6a680ca..986333c28 100644 --- a/.github/workflows/build-base-image.yml +++ b/.github/workflows/build-base-image.yml @@ -4,6 +4,7 @@ on: pull_request: branches: - 'dev' + - 'osm' - 'v*' paths: - poetry.lock diff --git a/Dockerfile b/Dockerfile index 316ab90a3..6550b780b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,10 +11,10 @@ RUN echo > /opt/jumpserver/config.yml \ if [ -n "${VERSION}" ]; then \ sed -i "s@VERSION = .*@VERSION = '${VERSION}'@g" apps/jumpserver/const.py; \ fi - RUN set -ex \ && export SECRET_KEY=$(head -c100 < /dev/urandom | base64 | tr -dc A-Za-z0-9 | head -c 48) \ && . /opt/py3/bin/activate \ + && test -f requirements/requirements.txt && uv pip install -r requirements/requirements.txt \ && cd apps \ && python manage.py compilemessages 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..14efb20a9 --- /dev/null +++ b/apps/common/api/webhook.py @@ -0,0 +1,66 @@ +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 + sender = data.get('sender', '') + 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_sender=sender, + event=event, + payload=payload, + headers=request.headers, + ) + return Response({'detail': 'Webhook accepted'}, status=status.HTTP_202_ACCEPTED) diff --git a/apps/common/decorators.py b/apps/common/decorators.py index 3ddee64ab..840101578 100644 --- a/apps/common/decorators.py +++ b/apps/common/decorators.py @@ -296,6 +296,31 @@ def cached_method(ttl=20): return decorator +def cached_method_to_redis(key, ttl, should_cache=None): + from django.core.cache import cache + + def decorator(func): + @wraps(func) + def wrapper(*args, **kwargs): + # 尝试从缓存读取 + cached_result = cache.get(key) + if cached_result is not None: + return cached_result + + # 执行函数 + result = func(*args, **kwargs) + + # 判断是否缓存 + if should_cache is None or should_cache(result): + cache.set(key, result, ttl) + + return result + + return wrapper + + return decorator + + def bulk_handle(handler, batch_size=50, timeout=0.5): def decorator(func): from orgs.utils import get_current_org_id 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/common/utils/__init__.py b/apps/common/utils/__init__.py index d34592a3a..e7f2fef70 100644 --- a/apps/common/utils/__init__.py +++ b/apps/common/utils/__init__.py @@ -8,5 +8,6 @@ from .encode import * from .http import * from .ip import * from .jumpserver import * +from .proxy import * from .random import * from .translate import * diff --git a/apps/common/utils/proxy.py b/apps/common/utils/proxy.py new file mode 100644 index 000000000..c45817476 --- /dev/null +++ b/apps/common/utils/proxy.py @@ -0,0 +1,62 @@ +# -*- coding: utf-8 -*- +# +import requests +import requests_unixsocket +from django.http import HttpResponse, QueryDict + + +def _get_headers(environ): + headers = {} + for key, value in environ.items(): + if key.startswith('HTTP_') and key != 'HTTP_HOST': + headers[key[5:].replace('_', '-')] = value + elif key in ('CONTENT_TYPE', 'CONTENT_LENGTH'): + headers[key.replace('_', '-')] = value + return headers + + +def unix_socket_proxy_view( + request, url, requests_args=None, rewrite_location=None, + error_prefix='Proxy request failed' + ): + requests_args = (requests_args or {}).copy() + headers = _get_headers(request.META) + params = request.GET.copy() + + if 'headers' not in requests_args: + requests_args['headers'] = {} + if 'data' not in requests_args: + requests_args['data'] = request.body + if 'params' not in requests_args: + requests_args['params'] = QueryDict('', mutable=True) + + headers.update(requests_args['headers']) + params.update(requests_args['params']) + + for key in list(headers.keys()): + if key.lower() == 'content-length': + del headers[key] + + requests_args['headers'] = headers + requests_args['params'] = params + + try: + with requests_unixsocket.Session() as session: + upstream_response = session.request(request.method, url, **requests_args) + except requests.exceptions.RequestException as exc: + return HttpResponse(f'{error_prefix}: {exc}', status=502) + + response = HttpResponse(upstream_response.content, status=upstream_response.status_code) + excluded_headers = { + 'connection', 'keep-alive', 'proxy-authenticate', + 'proxy-authorization', 'te', 'trailers', 'transfer-encoding', + 'upgrade', 'content-encoding', 'content-length', + } + for key, value in upstream_response.headers.items(): + if key.lower() in excluded_headers: + continue + if key.lower() == 'location' and callable(rewrite_location): + value = rewrite_location(value) + response[key] = value + + return response diff --git a/apps/jumpserver/conf.py b/apps/jumpserver/conf.py index e6e01c971..5d2cf11ea 100644 --- a/apps/jumpserver/conf.py +++ b/apps/jumpserver/conf.py @@ -750,6 +750,15 @@ class Config(dict): # Custom middlewares 'PRE_CUSTOM_MIDDLEWARES': '', 'POST_CUSTOM_MIDDLEWARES': '', + + # JDMC + '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/middleware.py b/apps/jumpserver/middleware.py index cfe26bb36..9c6113b3a 100644 --- a/apps/jumpserver/middleware.py +++ b/apps/jumpserver/middleware.py @@ -10,6 +10,7 @@ import pytz from django.conf import settings from django.core.exceptions import MiddlewareNotUsed from django.db.utils import OperationalError +from django.middleware.csrf import CsrfViewMiddleware from django.http.response import HttpResponseForbidden, JsonResponse from django.shortcuts import HttpResponse from django.shortcuts import redirect @@ -19,6 +20,7 @@ from rest_framework import status from .utils import set_current_request +IGNORE_CSRF_CHECK = '*' in os.getenv("DOMAINS", "").split(',') class TimezoneMiddleware: def __init__(self, get_response): @@ -191,3 +193,10 @@ class SafeRedirectMiddleware: host, port = netloc.split(':', 1) return host, port return netloc, '80' + + +class CsrfCheckMiddleware(CsrfViewMiddleware): + def _origin_verified(self, request): + if IGNORE_CSRF_CHECK: + return True + return super()._origin_verified(request) diff --git a/apps/jumpserver/routing.py b/apps/jumpserver/routing.py index 1075cb584..4b6257c5d 100644 --- a/apps/jumpserver/routing.py +++ b/apps/jumpserver/routing.py @@ -13,6 +13,10 @@ from notifications.urls.ws_urls import urlpatterns as notifications_urlpatterns from ops.urls.ws_urls import urlpatterns as ops_urlpatterns from settings.urls.ws_urls import urlpatterns as setting_urlpatterns from terminal.urls.ws_urls import urlpatterns as terminal_urlpatterns +from common.utils import get_logger +import socket + +logger = get_logger(__name__) __all__ = ['urlpatterns', 'application'] @@ -23,6 +27,7 @@ urlpatterns = ops_urlpatterns + \ if settings.XPACK_ENABLED: from xpack.plugins.cloud.urls.ws_urls import urlpatterns as xcloud_urlpatterns + urlpatterns += xcloud_urlpatterns @@ -58,14 +63,53 @@ class WsSignatureAuthMiddleware: return await self.app(scope, receive, send) +class SocketContextMiddleware: + def __init__(self, app): + self.app = app + + async def __call__(self, scope, receive, send): + fno = self._extract_socket_fno(scope, receive) + + if fno: + logger.debug(f"Successfully extracted FNO: {fno}") + scope['fno'] = fno + else: + logger.debug(f"Failed to trace FNO for {scope.get('client')}") + + return await self.app(scope, receive, send) + + @staticmethod + def _extract_socket_fno(scope, receive) -> int: + try: + transport = scope.get('extensions', {}).get('transport') + if not transport and receive: + protocol = getattr(receive, "__self__", None) + if protocol: + transport = getattr(protocol, "transport", None) + + if transport: + sock = transport.get_extra_info('socket') + if sock: + return sock.fileno() + + except Exception as e: + logger.error(f"Internal error during FNO extraction: {e}") + + return None + + application = ProtocolTypeRouter({ # Django's ASGI application to handle traditional HTTP requests - "http": get_asgi_application(), + "http": SocketContextMiddleware( + get_asgi_application() + ), # WebSocket chat handler - "websocket": WsSignatureAuthMiddleware( - AuthMiddlewareStack( - URLRouter(urlpatterns) + "websocket": SocketContextMiddleware( + WsSignatureAuthMiddleware( + AuthMiddlewareStack( + URLRouter(urlpatterns) + ) ) ), }) diff --git a/apps/jumpserver/settings/base.py b/apps/jumpserver/settings/base.py index 1e33933cd..2b3da04fd 100644 --- a/apps/jumpserver/settings/base.py +++ b/apps/jumpserver/settings/base.py @@ -92,6 +92,9 @@ ALLOWED_HOSTS = ['*'] # https://docs.djangoproject.com/en/4.1/ref/settings/#std-setting-CSRF_TRUSTED_ORIGINS CSRF_TRUSTED_ORIGINS = [] for host_port in ALLOWED_DOMAINS: + if '*' in ALLOWED_DOMAINS: + CSRF_TRUSTED_ORIGINS = ['http://*', 'https://*'] + break origin = host_port.strip('.') if not origin: @@ -171,7 +174,8 @@ MIDDLEWARE = [ 'django.middleware.locale.LocaleMiddleware', 'corsheaders.middleware.CorsMiddleware', 'django.middleware.common.CommonMiddleware', - 'django.middleware.csrf.CsrfViewMiddleware', + # 'django.middleware.csrf.CsrfViewMiddleware', + 'jumpserver.middleware.CsrfCheckMiddleware', 'django.contrib.auth.middleware.AuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', diff --git a/apps/jumpserver/settings/custom.py b/apps/jumpserver/settings/custom.py index 1c553ced0..9f970c1b6 100644 --- a/apps/jumpserver/settings/custom.py +++ b/apps/jumpserver/settings/custom.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # from pathlib import Path +from urllib.parse import quote from .base import TEMPLATES, STATIC_DIR from ..const import CONFIG @@ -275,3 +276,13 @@ VENDOR = CONFIG.VENDOR VENDOR_TEMPLATES_DIR = Path(STATIC_DIR) / VENDOR if Path(VENDOR_TEMPLATES_DIR).is_dir(): TEMPLATES[0]['DIRS'].insert(0, VENDOR_TEMPLATES_DIR) + +# JDMC +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 + +# WebHook +WEBHOOK_ENABLED = CONFIG.WEBHOOK_ENABLED +WEBHOOK_TOKEN = CONFIG.WEBHOOK_TOKEN diff --git a/apps/jumpserver/urls.py b/apps/jumpserver/urls.py index 328cdec1a..6e53b62d9 100644 --- a/apps/jumpserver/urls.py +++ b/apps/jumpserver/urls.py @@ -109,5 +109,10 @@ if os.environ.get('DEBUG_TOOLBAR', False): path('__debug__/', include('debug_toolbar.urls')), ] +if settings.JDMC_ENABLED: + urlpatterns += [ + re_path(r'jdmc/(?P.*)$', views.jdmc_proxy_view, name='jdmc-proxy'), + ] + handler404 = 'jumpserver.views.handler404' handler500 = 'jumpserver.views.handler500' diff --git a/apps/jumpserver/views/__init__.py b/apps/jumpserver/views/__init__.py index 990d22bab..c0c8bb503 100644 --- a/apps/jumpserver/views/__init__.py +++ b/apps/jumpserver/views/__init__.py @@ -5,3 +5,4 @@ from .error_views import * from .index import * from .other import * from .swagger import * +from .jdmc import * diff --git a/apps/jumpserver/views/jdmc.py b/apps/jumpserver/views/jdmc.py new file mode 100644 index 000000000..4442441e8 --- /dev/null +++ b/apps/jumpserver/views/jdmc.py @@ -0,0 +1,44 @@ +from django.conf import settings +from django.http import HttpResponse +from django.contrib.auth.views import redirect_to_login +from django.views.decorators.csrf import csrf_exempt +from urllib.parse import quote, urlsplit, urlunsplit +from common.utils.proxy import unix_socket_proxy_view + + +__all__ = ['jdmc_proxy_view'] + + +def _rewrite_location(location): + if not location: + return location + if location.startswith('http+unix://'): + parsed = urlsplit(location) + return urlunsplit(('', '', parsed.path, parsed.query, parsed.fragment)) + return location + + +@csrf_exempt +def jdmc_proxy_view(request, subpath=''): + if not request.user.is_authenticated or not request.user.is_superuser: + return redirect_to_login(request.get_full_path(), settings.LOGIN_URL) + + upstream_url = f"{settings.JDMC_BASE_URL}{request.path}" + requests_args = { + 'headers': { + 'X-Forwarded-Proto': request.scheme, + 'X-Forwarded-Host': request.get_host(), + }, + 'allow_redirects': False, + 'timeout': (5, 60), + } + if request.method in {'GET', 'HEAD'}: + requests_args['data'] = None + + return unix_socket_proxy_view( + request=request, + url=upstream_url, + requests_args=requests_args, + rewrite_location=_rewrite_location, + error_prefix='JDMC proxy failed', + ) diff --git a/apps/settings/serializers/public.py b/apps/settings/serializers/public.py index a6ae916f2..6e5ea0eee 100644 --- a/apps/settings/serializers/public.py +++ b/apps/settings/serializers/public.py @@ -87,6 +87,8 @@ class PrivateSettingSerializer(PublicSettingSerializer): PRIVACY_MODE = serializers.BooleanField() CHANGE_SECRET_AFTER_SESSION_END = serializers.BooleanField() + JDMC_ENABLED = serializers.BooleanField() + class ServerInfoSerializer(serializers.Serializer): CURRENT_TIME = serializers.DateTimeField() diff --git a/pyproject.toml b/pyproject.toml index 5cdffd096..13562bbe2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -153,6 +153,7 @@ dependencies = [ 'drf-spectacular-sidecar==2025.8.1', "django-oauth-toolkit==2.4.0", "pyhttpsig==1.3.0", + "requests-unixsocket==0.4.1", ] [project.urls] diff --git a/requirements/requirements.txt b/requirements/requirements.txt new file mode 100644 index 000000000..2718a2afb --- /dev/null +++ b/requirements/requirements.txt @@ -0,0 +1 @@ +requests-unixsocket==0.4.1 \ No newline at end of file diff --git a/uv.lock b/uv.lock index f9ebad0da..b8d16119f 100644 --- a/uv.lock +++ b/uv.lock @@ -2442,6 +2442,7 @@ dependencies = [ { name = "pyzipper" }, { name = "redis" }, { name = "requests" }, + { name = "requests-unixsocket" }, { name = "rest-condition" }, { name = "s3transfer" }, { name = "simplejson" }, @@ -2597,7 +2598,7 @@ requires-dist = [ { name = "pyexcel-xlsx", specifier = "==0.6.0" }, { name = "pyfreerdp", specifier = "==0.0.2" }, { name = "pyhcl", specifier = "==0.4.4" }, - { name = "pyhttpsig", specifier = ">=1.3.0" }, + { name = "pyhttpsig", specifier = "==1.3.0" }, { name = "pyjwkest", specifier = "==1.4.2" }, { name = "pymongo", specifier = "==4.6.3" }, { name = "pympler", specifier = "==1.0.1" }, @@ -2619,6 +2620,7 @@ requires-dist = [ { name = "pyzipper", specifier = "==0.3.6" }, { name = "redis", url = "https://github.com/jumpserver-dev/redis-py/archive/refs/tags/v5.0.3.zip" }, { name = "requests", specifier = "==2.32.4" }, + { name = "requests-unixsocket", specifier = ">=0.4.1" }, { name = "rest-condition", specifier = "==1.0.3" }, { name = "s3transfer", specifier = "==0.6.1" }, { name = "simplejson", specifier = "==3.19.1" }, @@ -4473,6 +4475,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" }, ] +[[package]] +name = "requests-unixsocket" +version = "0.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/80/44636b363e75af7f5e6ac0571137466fec66e47016179139d0019a453ab7/requests_unixsocket-0.4.1.tar.gz", hash = "sha256:b2596158c356ecee68d27ba469a52211230ac6fb0cde8b66afb19f0ed47a1995", size = 23476, upload-time = "2025-03-07T18:12:48.3Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/86/df4771915e64a564c577ea2573956861c9c9f6c79450b172c5f9277cc48a/requests_unixsocket-0.4.1-py3-none-any.whl", hash = "sha256:60c4942e9dbecc2f64d611039fb1dfc25da382083c6434ac0316dca3ff908f4d", size = 11340, upload-time = "2025-03-07T18:12:47.188Z" }, +] + [[package]] name = "resolvelib" version = "0.8.1"