merge: with remote

This commit is contained in:
ibuler
2026-03-12 13:54:51 +08:00
22 changed files with 314 additions and 8 deletions

View File

@@ -4,6 +4,7 @@ on:
pull_request:
branches:
- 'dev'
- 'osm'
- 'v*'
paths:
- poetry.lock

View File

@@ -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

View File

@@ -5,3 +5,4 @@ from .mixin import *
from .patch import *
from .permission import *
from .serializer import *
from .webhook import *

View 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)

View File

@@ -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

View File

@@ -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+)`')

View File

@@ -4,3 +4,4 @@
from django.dispatch import Signal
django_ready = Signal()
webhook_signal = Signal()

View File

@@ -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'))

View File

@@ -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 *

View 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

View File

@@ -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 = {

View File

@@ -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)

View File

@@ -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)
)
)
),
})

View File

@@ -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',

View File

@@ -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

View File

@@ -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'

View File

@@ -5,3 +5,4 @@ from .error_views import *
from .index import *
from .other import *
from .swagger import *
from .jdmc import *

View 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',
)

View File

@@ -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()

View File

@@ -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]

View File

@@ -0,0 +1 @@
requests-unixsocket==0.4.1

16
uv.lock generated
View File

@@ -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"