mirror of
https://github.com/jumpserver/jumpserver.git
synced 2026-03-18 11:02:09 +00:00
merge: with remote
This commit is contained in:
1
.github/workflows/build-base-image.yml
vendored
1
.github/workflows/build-base-image.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
branches:
|
||||
- 'dev'
|
||||
- 'osm'
|
||||
- 'v*'
|
||||
paths:
|
||||
- poetry.lock
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -5,3 +5,4 @@ from .mixin import *
|
||||
from .patch import *
|
||||
from .permission import *
|
||||
from .serializer import *
|
||||
from .webhook import *
|
||||
|
||||
66
apps/common/api/webhook.py
Normal file
66
apps/common/api/webhook.py
Normal file
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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+)`')
|
||||
|
||||
@@ -4,3 +4,4 @@
|
||||
from django.dispatch import Signal
|
||||
|
||||
django_ready = Signal()
|
||||
webhook_signal = Signal()
|
||||
|
||||
@@ -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'))
|
||||
@@ -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 *
|
||||
|
||||
62
apps/common/utils/proxy.py
Normal file
62
apps/common/utils/proxy.py
Normal file
@@ -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
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
)
|
||||
),
|
||||
})
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<subpath>.*)$', views.jdmc_proxy_view, name='jdmc-proxy'),
|
||||
]
|
||||
|
||||
handler404 = 'jumpserver.views.handler404'
|
||||
handler500 = 'jumpserver.views.handler500'
|
||||
|
||||
@@ -5,3 +5,4 @@ from .error_views import *
|
||||
from .index import *
|
||||
from .other import *
|
||||
from .swagger import *
|
||||
from .jdmc import *
|
||||
|
||||
44
apps/jumpserver/views/jdmc.py
Normal file
44
apps/jumpserver/views/jdmc.py
Normal file
@@ -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',
|
||||
)
|
||||
@@ -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()
|
||||
|
||||
@@ -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]
|
||||
|
||||
1
requirements/requirements.txt
Normal file
1
requirements/requirements.txt
Normal file
@@ -0,0 +1 @@
|
||||
requests-unixsocket==0.4.1
|
||||
16
uv.lock
generated
16
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user