Compare commits

...

16 Commits

Author SHA1 Message Date
ibuler
777b655e90 perf: stash it 2025-11-03 18:04:12 +08:00
ibuler
6e1736c1a7 stash 2025-11-03 14:26:36 +08:00
ibuler
fe31eb0a44 feat: mcp internal 2025-10-31 14:14:23 +08:00
feng
3e93034fbc perf: Update remote_client 2025-10-30 10:12:40 +08:00
feng
f4b3a7d73a perf: Sync feishu info 2025-10-29 14:53:45 +08:00
wrd
3781c40179 Revert "perf: update fields serialization and bump django and djangorestframe…"
This reverts commit dd0cacb4bc.
2025-10-29 11:19:50 +08:00
ibuler
fab6219cea perf: branches auto cleanup 2025-10-29 10:10:21 +08:00
fit2bot
dd0cacb4bc perf: update fields serialization and bump django and djangorestframework versions (#16209)
Co-authored-by: wangruidong <940853815@qq.com>
2025-10-28 16:42:06 +08:00
ibuler
b8639601a1 perf: branches auto cleanup 2025-10-27 15:33:06 +08:00
老广
ab9882c9c1 perf: check api summary 2025-10-27 15:28:21 +08:00
ibuler
77a7b74b15 perf: print summary in the end 2025-10-27 15:26:04 +08:00
dependabot[bot]
4bc05865f1 chore(deps): bump python-ldap from 3.4.3 to 3.4.5
Bumps [python-ldap](https://github.com/python-ldap/python-ldap) from 3.4.3 to 3.4.5.
- [Release notes](https://github.com/python-ldap/python-ldap/releases)
- [Changelog](https://github.com/python-ldap/python-ldap/blob/python-ldap-3.4.5/CHANGES)
- [Commits](https://github.com/python-ldap/python-ldap/compare/python-ldap-3.4.3...python-ldap-3.4.5)

---
updated-dependencies:
- dependency-name: python-ldap
  dependency-version: 3.4.5
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 15:24:06 +08:00
fit2bot
bec9e4f3a7 perf: update deps kombu (#16133)
* perf: update deps kombu

* perf: Update Dockerfile with new base image tag

---------

Co-authored-by: Ewall555 <a03216@foxmail.com>
Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: wrd <940853815@qq.com>
2025-10-27 15:18:16 +08:00
fit2bot
359adf3dbb perf: add check api for common user 2025-10-27 14:54:02 +08:00
feng
ac54bb672c fix: Bulk account invalid secret_reset 2025-10-24 18:18:16 +08:00
ibuler
9e3ba00bc4 perf: search support keyword q=str to search 2025-10-24 10:22:49 +08:00
22 changed files with 460 additions and 90 deletions

View File

@@ -34,7 +34,7 @@ jobs:
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "dry_run=${{ github.event.inputs.dry_run }}" >> $GITHUB_OUTPUT
else
echo "dry_run=true" >> $GITHUB_OUTPUT
echo "dry_run=false" >> $GITHUB_OUTPUT
fi
- name: Cleanup branches

View File

@@ -25,7 +25,8 @@ class IntegrationApplicationViewSet(OrgBulkModelViewSet):
}
rbac_perms = {
'get_once_secret': 'accounts.change_integrationapplication',
'get_account_secret': 'accounts.view_integrationapplication'
'get_account_secret': 'accounts.view_integrationapplication',
'get_sdks_info': 'accounts.view_integrationapplication'
}
def read_file(self, path):
@@ -36,7 +37,6 @@ class IntegrationApplicationViewSet(OrgBulkModelViewSet):
@action(
['GET'], detail=False, url_path='sdks',
permission_classes=[IsValidUser]
)
def get_sdks_info(self, request, *args, **kwargs):
code_suffix_mapper = {

View File

@@ -309,10 +309,10 @@ class AssetAccountBulkSerializer(
class Meta:
model = Account
fields = [
'name', 'username', 'secret', 'secret_type', 'passphrase',
'privileged', 'is_active', 'comment', 'template',
'on_invalid', 'push_now', 'params', 'assets',
'su_from_username', 'source', 'source_id',
'name', 'username', 'secret', 'secret_type', 'secret_reset',
'passphrase', 'privileged', 'is_active', 'comment', 'template',
'on_invalid', 'push_now', 'params', 'assets', 'su_from_username',
'source', 'source_id',
]
extra_kwargs = {
'name': {'required': False},

View File

@@ -113,7 +113,7 @@ class BaseAssetViewSet(OrgBulkModelViewSet):
("accounts", AccountSerializer),
)
rbac_perms = (
("match", "assets.match_asset"),
("match", "assets.view_asset"),
("platform", "assets.view_platform"),
("gateways", "assets.view_gateway"),
("accounts", "assets.view_account"),

View File

@@ -43,7 +43,7 @@ class NodeViewSet(SuggestionMixin, OrgBulkModelViewSet):
search_fields = ('full_value',)
serializer_class = serializers.NodeSerializer
rbac_perms = {
'match': 'assets.match_node',
'match': 'assets.view_node',
'check_assets_amount_task': 'assets.change_node'
}

View File

@@ -112,8 +112,10 @@ class PlatformProtocolViewSet(JMSModelViewSet):
class PlatformAutomationMethodsApi(generics.ListAPIView):
permission_classes = (IsValidUser,)
queryset = PlatformAutomation.objects.none()
rbac_perms = {
'list': 'assets.view_platform'
}
@staticmethod
def automation_methods():

View File

@@ -1,5 +1,6 @@
# -*- coding: utf-8 -*-
#
from rest_framework.filters import SearchFilter as SearchFilterBase
import base64
import json
import logging
@@ -35,6 +36,14 @@ __all__ = [
]
class SearchFilter(SearchFilterBase):
def get_search_terms(self, request):
params = request.query_params.get(self.search_param, '') or request.query_params.get('search', '')
params = params.replace('\x00', '') # strip null characters
params = params.replace(',', ' ')
return params.split()
class BaseFilterSet(drf_filters.FilterSet):
days = drf_filters.NumberFilter(method="filter_days")
days__lt = drf_filters.NumberFilter(method="filter_days")

View File

@@ -1,9 +1,13 @@
import re
import uuid
import time
from django.conf import settings
from django.core.management.base import BaseCommand
from django.test import Client
from django.urls import URLPattern, URLResolver
from django.contrib.auth import get_user_model
from django.contrib.auth.models import AnonymousUser
from jumpserver.urls import api_v1
@@ -85,50 +89,262 @@ known_error_urls = [
'/api/v1/terminal/sessions/00000000-0000-0000-0000-000000000000/replay/download/',
]
# API 白名单 - 普通用户可以访问的 API
user_accessible_urls = known_unauth_urls + [
# 添加更多普通用户可以访问的 API
"/api/v1/settings/public/",
"/api/v1/users/profile/",
"/api/v1/users/change-password/",
"/api/v1/users/logout/",
"/api/v1/settings/chatai-prompts/",
"/api/v1/authentication/confirm/",
"/api/v1/users/connection-token/",
"/api/v1/authentication/temp-tokens/",
"/api/v1/notifications/backends/",
"/api/v1/authentication/passkeys/",
"/api/v1/orgs/orgs/current/",
"/api/v1/tickets/apply-asset-tickets/",
"/api/v1/ops/celery/task/00000000-0000-0000-0000-000000000000/task-execution/00000000-0000-0000-0000-000000000000/log/",
"/api/v1/assets/favorite-assets/",
"/api/v1/authentication/connection-token/",
"/api/v1/ops/jobs/",
"/api/v1/assets/categories/",
"/api/v1/tickets/tickets/",
"/api/v1/authentication/ssh-key/",
"/api/v1/terminal/my-sessions/",
"/api/v1/authentication/access-keys/",
"/api/v1/users/profile/permissions/",
"/api/v1/tickets/apply-login-asset-tickets/",
"/api/v1/resources/",
"/api/v1/ops/celery/task/00000000-0000-0000-0000-000000000000/task-execution/00000000-0000-0000-0000-000000000000/result/",
"/api/v1/notifications/site-messages/",
"/api/v1/notifications/site-messages/unread-total/",
"/api/v1/assets/assets/suggestions/",
"/api/v1/search/",
"/api/v1/notifications/user-msg-subscription/",
"/api/v1/ops/ansible/job-execution/00000000-0000-0000-0000-000000000000/log/",
"/api/v1/tickets/apply-login-tickets/",
"/api/v1/ops/variables/form-data/",
"/api/v1/ops/variables/help/",
"/api/v1/users/profile/password/",
"/api/v1/tickets/apply-command-tickets/",
"/api/v1/ops/job-executions/",
"/api/v1/audits/my-login-logs/",
"/api/v1/terminal/components/connect-methods/"
"/api/v1/ops/task-executions/",
"/api/v1/terminal/sessions/online-info/",
"/api/v1/ops/adhocs/",
"/api/v1/tickets/apply-nodes/suggestions/",
"/api/v1/tickets/apply-assets/suggestions/",
"/api/v1/settings/server-info/",
"/api/v1/ops/playbooks/",
"/api/v1/assets/categories/types/",
"/api/v1/assets/protocols/",
"/api/v1/common/countries/",
"/api/v1/audits/jobs/",
"/api/v1/terminal/components/connect-methods/",
"/api/v1/ops/task-executions/",
]
errors = {}
class Command(BaseCommand):
help = 'Check api if unauthorized'
"""
Check API authorization and user access permissions.
This command performs two types of checks:
1. Anonymous access check - finds APIs that can be accessed without authentication
2. User access check - finds APIs that can be accessed by a normal user
The functionality is split into two methods:
- check_anonymous_access(): Checks for APIs accessible without authentication
- check_user_access(): Checks for APIs accessible by a normal user
Usage examples:
# Check both anonymous and user access (default behavior)
python manage.py check_api
# Check only anonymous access
python manage.py check_api --skip-user-check
# Check only user access
python manage.py check_api --skip-anonymous-check
# Check user access and update whitelist
python manage.py check_api --update-whitelist
"""
help = 'Check API authorization and user access permissions'
password = uuid.uuid4().hex
unauth_urls = []
error_urls = []
unformat_urls = []
# 用户可以访问的 API但不在白名单中的 API
unexpected_access = []
def handle(self, *args, **options):
settings.LOG_LEVEL = 'ERROR'
urls = get_api_urls()
def add_arguments(self, parser):
parser.add_argument(
'--skip-anonymous-check',
action='store_true',
help='Skip anonymous access check (only check user access)',
)
parser.add_argument(
'--skip-user-check',
action='store_true',
help='Skip user access check (only check anonymous access)',
)
parser.add_argument(
'--update-whitelist',
action='store_true',
help='Update the user accessible URLs whitelist based on current scan results',
)
def create_test_user(self):
"""创建测试用户"""
User = get_user_model()
username = 'test_user_api_check'
email = 'test@example.com'
# 删除可能存在的测试用户
User.objects.filter(username=username).delete()
# 创建新的测试用户
user = User.objects.create_user(
username=username,
email=email,
password=self.password,
is_active=True
)
return user
def check_user_api_access(self, urls):
"""检查普通用户可以访问的 API"""
user = self.create_test_user()
client = Client()
client.defaults['HTTP_HOST'] = 'localhost'
unauth_urls = []
# 登录用户
login_success = client.login(username=user.username, password=self.password)
if not login_success:
self.stdout.write(
self.style.ERROR('Failed to login test user')
)
return [], []
accessible_urls = []
error_urls = []
unformat_urls = []
self.stdout.write('Checking user API access...')
for url, ourl in urls:
if '(' in url or '<' in url:
continue
try:
response = client.get(url, follow=True)
time.sleep(0.1)
# 如果状态码是 200 或 201说明用户可以访问
if response.status_code in [200, 201]:
accessible_urls.append((url, ourl, response.status_code))
elif response.status_code == 403:
# 403 表示权限不足,这是正常的
pass
else:
# 其他状态码可能是错误
error_urls.append((url, ourl, response.status_code))
except Exception as e:
error_urls.append((url, ourl, str(e)))
# 清理测试用户
user.delete()
return accessible_urls, error_urls
def check_anonymous_access(self, urls):
"""检查匿名访问权限"""
client = Client()
client.defaults['HTTP_HOST'] = 'localhost'
for url, ourl in urls:
if '(' in url or '<' in url:
unformat_urls.append([url, ourl])
self.unformat_urls.append([url, ourl])
continue
try:
response = client.get(url, follow=True)
if response.status_code != 401:
errors[url] = str(response.status_code) + ' ' + str(ourl)
unauth_urls.append(url)
self.unauth_urls.append(url)
except Exception as e:
errors[url] = str(e)
error_urls.append(url)
self.error_urls.append(url)
unauth_urls = set(unauth_urls) - set(known_unauth_urls)
print("\nUnauthorized urls:")
if not unauth_urls:
self.unauth_urls = set(self.unauth_urls) - set(known_unauth_urls)
self.error_urls = set(self.error_urls)
self.unformat_urls = set(self.unformat_urls)
def print_anonymous_access_result(self):
print("\n=== Anonymous Access Check ===")
print("Unauthorized urls:")
if not self.unauth_urls:
print(" Empty, very good!")
for url in unauth_urls:
for url in self.unauth_urls:
print('"{}", {}'.format(url, errors.get(url, '')))
print("\nError urls:")
if not error_urls:
if not self.error_urls:
print(" Empty, very good!")
for url in set(error_urls):
for url in set(self.error_urls):
print(url, ': ' + errors.get(url))
print("\nUnformat urls:")
if not unformat_urls:
if not self.unformat_urls:
print(" Empty, very good!")
for url in unformat_urls:
for url in self.unformat_urls:
print(url)
def check_user_access(self, urls, update_whitelist=False):
"""检查用户访问权限"""
print("\n=== User Access Check ===")
accessible_urls, user_error_urls = self.check_user_api_access(urls)
# 检查是否有不在白名单中的可访问 API
accessible_url_list = [url for url, _, _ in accessible_urls]
unexpected_access = set(accessible_url_list) - set(user_accessible_urls)
self.unexpected_access = unexpected_access
# 如果启用了更新白名单选项
if update_whitelist:
print("\n=== Updating Whitelist ===")
new_whitelist = sorted(set(user_accessible_urls + accessible_url_list))
print("Updated whitelist would include:")
for url in new_whitelist:
print(f' "{url}",')
print(f"\nTotal URLs in whitelist: {len(new_whitelist)}")
def print_user_access_result(self):
print("\n=== User Access Check ===")
print("User unexpected urls:")
if self.unexpected_access:
print(f" Error: Found {len(self.unexpected_access)} URLs accessible by user but not in whitelist:")
for url in self.unexpected_access:
print(f' "{url}"')
else:
print(" Empty, very good!")
def handle(self, *args, **options):
settings.LOG_LEVEL = 'ERROR'
urls = get_api_urls()
# 检查匿名访问权限(默认执行)
if not options['skip_anonymous_check']:
self.check_anonymous_access(urls)
# 检查用户访问权限(默认执行)
if not options['skip_user_check']:
self.check_user_access(urls, options['update_whitelist'])
print("\nCheck total urls: ", len(urls))
self.print_anonymous_access_result()
self.print_user_access_result()

View File

@@ -162,6 +162,7 @@ class FeiShu(RequestMixin):
except Exception as e:
logger.error(f'Get user detail error: {e} data={data}')
data.update(kwargs['other_info'] if 'other_info' in kwargs else {})
info = flatten_dict(data)
default_detail = self.default_user_detail(data, user_id)
detail = map_attributes(default_detail, info, self.attributes)

View File

@@ -1,3 +1,5 @@
#!/usr/bin/env python3
list_params = [
{
"name": "search",
@@ -11,41 +13,99 @@ list_params = [
"in": "query",
"description": "Which field to use when ordering the results.",
"required": False,
"type": "string"
"type": "string",
"enum": [
"name", "date_created", "date_updated", "created_by",
]
},
{
"name": "limit",
"in": "query",
"description": "Number of results to return per page. Default is 10.",
"required": False,
"type": "integer"
"type": "integer",
"default": 10
},
{
"name": "offset",
"in": "query",
"description": "The initial index from which to return the results.",
"required": False,
"type": "integer"
"type": "integer",
"default": 0
},
]
unsupported_resources = [
"customs", "protocol-settings", "gathered-accounts",
"account-template-secrets", 'change-secret-records',
'account-backup-executions', 'change-secret-executions',
'change-secret-status', 'gather-account-executions',
'push-account-executions', 'check-account-executions',
'integration-apps', 'asset-permissions-users-relations',
'asset-permissions-user-groups-relations', 'asset-permissions-assets-relations',
'asset-permissions-nodes-relations', 'terminal-status', 'tasks', 'status',
'session-sharing-records', 'endpoints', 'endpoint-rules',
'chatai-prompts', 'leak-passwords', 'super-connection-tokens',
'system-role-bindings', 'org-role-bindings', 'content-types', 'system-role-permissions',
'org-role-permissions', 'system-msg-subscriptions',
'celery-period-tasks', 'task-executions', 'adhocs',
'user-sessions', 'service-access-logs',
'applet-publications', 'applet-host-deployments',
'virtual-app-publications', 'applet-host-accounts', 'applet-host-applets',
'flows'
]
supported_resources = [
# User
'users', 'user-groups', 'users-groups-relations',
# Asset
'assets', 'hosts', 'devices', 'databases', 'webs', 'clouds', 'gpts',
'ds', 'platforms', 'nodes', 'zones', 'gateways',
# Account
'virtual-accounts', 'account-templates', 'account-backups',
# Automation
'change-secret-automations', 'gather-account-automations',
'push-account-automations', 'check-account-automations',
'account-risks',
# Permission
'asset-permissions',
# Terminal
'terminals', 'replay-storages', 'command-storages',
# Applet
'applets', 'applet-hosts', 'virtual-apps',
# Ops
'playbooks', 'jobs',
# Audit
'ftp-logs', 'login-logs', 'operate-logs', 'password-change-logs', 'job-logs',
# Tickets
'tickets', 'comments', 'apply-assets', 'apply-nodes',
# Acls
'login-acls', 'login-asset-acls', 'command-filter-acls',
'command-groups', 'connect-method-acls', 'data-masking-rules',
# RBAC
'roles', 'role-bindings',
'system-roles', 'org-roles',
# Label
'labeled-resources',
'labels',
]
common_params = [
{
"name": "resource",
"in": "path",
"description": """Resource to query, e.g. users, assets, permissions, acls, user-groups, policies, nodes, hosts,
devices, clouds, webs, databases,
gpts, ds, customs, platforms, zones, gateways, protocol-settings, labels, virtual-accounts,
gathered-accounts, account-templates, account-template-secrets, account-backups, account-backup-executions,
change-secret-automations, change-secret-executions, change-secret-records, gather-account-automations,
gather-account-executions, push-account-automations, push-account-executions, push-account-records,
check-account-automations, check-account-executions, account-risks, integration-apps, asset-permissions,
zones, gateways, virtual-accounts, gathered-accounts, account-templates, account-template-secrets,,
GET /api/v1/resources/ to get full supported resource.
""",
"description": f"""Resource to query, {supported_resources}
GET /api/v1/resources/ to get full supported resource.
if you want to get the resource list, you can set the resource name in the url.
if you want to create a resource, you can set the resource name in the url.
if you want to get the resource detail, you can set the resource name and id in the url.
if you want to update the resource, you can set the resource name and id in the url.
if you want to delete the resource, you can set the resource name and id in the url.
""",
"required": True,
"type": "string"
"type": "string",
"enum": supported_resources
},
{
"name": "X-JMS-ORG",

View File

@@ -69,13 +69,13 @@ class ResourceListApi(ProxyMixin, APIView):
return self._proxy(request, resource)
@extend_schema(
operation_id="create_resource_by_type",
operation_id="create_resource",
summary="Create resource",
parameters=create_params,
description="""
Create resource,
OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and helptext, and
you will know how to create it.
OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and help text,
and you will know how to create it.
""",
)
def post(self, request, resource, pk=None):

View File

@@ -7,6 +7,7 @@ from rest_framework.response import Response
from rest_framework.routers import DefaultRouter
from rest_framework.views import APIView
from .const import supported_resources
from .utils import get_full_resource_map
router = DefaultRouter()
@@ -35,6 +36,8 @@ class ResourceTypeListApi(APIView):
result = []
resource_map = get_full_resource_map()
for name, desc in resource_map.items():
if name not in supported_resources:
continue
desc = resource_map.get(name, {})
resource = {
"name": name,

View File

@@ -4,8 +4,9 @@ import re
from functools import lru_cache
from typing import Dict
from django.urls import URLPattern
from django.urls import URLResolver
from django.utils.functional import LazyObject
from django.urls import URLPattern, URLResolver
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter
from rest_framework.routers import DefaultRouter
@@ -114,9 +115,37 @@ def extract_resource_paths(urlpatterns, prefix='/api/v1/') -> Dict[str, Dict[str
def param_dic_to_param(d):
# 将 'in' 字段映射到 OpenApiParameter 的 location 常量
location_map = {
'query': OpenApiParameter.QUERY,
'path': OpenApiParameter.PATH,
'header': OpenApiParameter.HEADER,
'cookie': OpenApiParameter.COOKIE,
}
location = location_map.get(d['in'], OpenApiParameter.QUERY)
# 将 type 字符串映射到 OpenApiTypes
type_map = {
'string': OpenApiTypes.STR,
'integer': OpenApiTypes.INT,
'number': OpenApiTypes.FLOAT,
'boolean': OpenApiTypes.BOOL,
'array': OpenApiTypes.OBJECT, # 对于 array 类型,需要特殊处理
'object': OpenApiTypes.OBJECT,
}
param_type = type_map.get(d['type'], OpenApiTypes.STR)
enum = None
if d.get('enum'):
enum = d['enum']
return OpenApiParameter(
name=d['name'], location=d['in'],
description=d['description'], type=d['type'], required=d.get('required', False)
name=d['name'],
location=location,
description=d['description'],
type=param_type,
required=d.get('required', False),
enum=enum
)

View File

@@ -732,6 +732,9 @@ class Config(dict):
# Suggestion api
'SUGGESTION_LIMIT': 10,
# MCP
'MCP_ENABLED': False,
}
old_config_map = {

View File

@@ -267,4 +267,5 @@ LOKI_BASE_URL = CONFIG.LOKI_BASE_URL
TOOL_USER_ENABLED = CONFIG.TOOL_USER_ENABLED
SUGGESTION_LIMIT = CONFIG.SUGGESTION_LIMIT
SUGGESTION_LIMIT = CONFIG.SUGGESTION_LIMIT
MCP_ENABLED = CONFIG.MCP_ENABLED

View File

@@ -38,12 +38,12 @@ REST_FRAMEWORK = {
),
'DEFAULT_FILTER_BACKENDS': (
'django_filters.rest_framework.DjangoFilterBackend',
'rest_framework.filters.SearchFilter',
'common.drf.filters.SearchFilter',
'common.drf.filters.RewriteOrderingFilter',
),
'DEFAULT_METADATA_CLASS': 'common.drf.metadata.SimpleMetadataWithFilters',
'ORDERING_PARAM': "order",
'SEARCH_PARAM': "search",
'SEARCH_PARAM': "q",
'DATETIME_FORMAT': '%Y/%m/%d %H:%M:%S %z',
'DATETIME_INPUT_FORMATS': ['%Y/%m/%d %H:%M:%S %z', 'iso-8601', '%Y-%m-%d %H:%M:%S %z'],
'DEFAULT_PAGINATION_CLASS': 'jumpserver.rewriting.pagination.MaxLimitOffsetPagination',

View File

@@ -35,11 +35,14 @@ resource_api = [
api_v1 = resource_api + [
path('prometheus/metrics/', api.PrometheusMetricsApi.as_view()),
path('resources/', api.ResourceTypeListApi.as_view(), name='resource-list'),
path('resources/<str:resource>/', api.ResourceListApi.as_view()),
path('resources/<str:resource>/<str:pk>/', api.ResourceDetailApi.as_view()),
path('search/', api.GlobalSearchView.as_view()),
]
if settings.MCP_ENABLED:
api_v1.extend([
path('resources/', api.ResourceTypeListApi.as_view(), name='resource-list'),
path('resources/<str:resource>/', api.ResourceListApi.as_view()),
path('resources/<str:resource>/<str:pk>/', api.ResourceDetailApi.as_view()),
])
app_view_patterns = [
path('auth/', include('authentication.urls.view_urls'), name='auth'),
@@ -85,7 +88,7 @@ urlpatterns += [
path('core/jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
]
DOC_TTL = 60 * 60
DOC_TTL = 1
DOC_VERSION = uuid.uuid4().hex
cache_kwargs = {
'cache_timeout': DOC_TTL,
@@ -95,7 +98,7 @@ cache_kwargs = {
}
# docs 路由
urlpatterns += [
path('api/swagger.json', views.get_swagger_view(ui='json', **cache_kwargs), name='schema-json'),
path('api/swagger.json', views.get_swagger_view(ui='json'), name='schema-json'),
path('api/swagger.yaml', views.get_swagger_view(ui='yaml', **cache_kwargs), name='schema'),
re_path('api/docs/?', views.get_swagger_view(ui='swagger', **cache_kwargs), name="docs"),
re_path('api/redoc/?', views.get_swagger_view(ui='redoc', **cache_kwargs), name='redoc'),

View File

@@ -14,7 +14,7 @@ class CustomSchemaGenerator(SchemaGenerator):
class CustomAutoSchema(AutoSchema):
def __init__(self, *args, **kwargs):
self.from_mcp = kwargs.get('from_mcp', False)
self.from_mcp = True
super().__init__(*args, **kwargs)
def map_parsers(self):
@@ -30,19 +30,19 @@ class CustomAutoSchema(AutoSchema):
tags = ['_'.join(operation_keys[:2])]
return tags
def get_operation(self, path, *args, **kwargs):
if path.endswith('render-to-json/'):
return None
# if not path.startswith('/api/v1/users'):
# return None
operation = super().get_operation(path, *args, **kwargs)
if not operation:
return operation
# def get_operation(self, path, *args, **kwargs):
# if path.endswith('render-to-json/'):
# return None
# # if not path.startswith('/api/v1/users'):
# # return None
# operation = super().get_operation(path, *args, **kwargs)
# if not operation:
# return operation
if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId')
# if not operation.get('summary', ''):
# operation['summary'] = operation.get('operationId')
return operation
# return operation
def get_operation_id(self):
tokenized_path = self._tokenize_path()
@@ -118,7 +118,8 @@ class CustomAutoSchema(AutoSchema):
'change-secret-dashboard', '/copy-to-assets/',
'/move-to-assets/', 'dashboard', 'index', 'countries',
'/resources/cache/', 'profile/mfa', 'profile/password',
'profile/permissions', 'prometheus', 'constraints'
'profile/permissions', 'prometheus', 'constraints',
'/api/swagger.json', '/api/swagger.yaml',
]
for p in excludes:
if path.find(p) >= 0:
@@ -132,14 +133,15 @@ class CustomAutoSchema(AutoSchema):
apps = []
if self.from_mcp:
apps = [
'ops', 'tickets', 'authentication',
'settings', 'xpack', 'terminal', 'rbac',
'notifications', 'promethues', 'acls'
]
# apps = [
# 'ops', 'tickets', 'authentication',
# 'settings', 'xpack', 'terminal', 'rbac',
# 'notifications', 'promethues', 'acls'
# ]
apps = ['resources']
app_name = parts[3]
if app_name in apps:
if app_name not in apps:
return True
models = []
model = parts[4]
@@ -190,6 +192,9 @@ class CustomAutoSchema(AutoSchema):
if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId')
if self.is_excluded():
return None
exclude_operations = [
'orgs_orgs_read', 'orgs_orgs_update', 'orgs_orgs_delete',

View File

@@ -9,15 +9,16 @@ from rest_framework.response import Response
from django.contrib.auth.mixins import LoginRequiredMixin
class SwaggerUI(LoginRequiredMixin, SpectacularSwaggerView):
class SwaggerUI( SpectacularSwaggerView):
pass
class Redoc(LoginRequiredMixin, SpectacularRedocView):
pass
class SchemeMixin:
permission_classes = []
def get(self, request, *args, **kwargs):
schema = super().get(request, *args, **kwargs).data
host = request.get_host()
@@ -37,7 +38,7 @@ class SchemeMixin:
}
return Response(schema)
@method_decorator(cache_page(60 * 5,), name="dispatch")
# @method_decorator(cache_page(60 * 5,), name="dispatch")
class JsonApi(SchemeMixin, SpectacularJSONAPIView):
pass

View File

@@ -160,6 +160,19 @@ class SSHClient:
try:
self.client.connect(**self.connect_params)
self._channel = self.client.invoke_shell()
# Always perform a gentle handshake that works for servers and
# network devices: drain banner, brief settle, send newline, then
# read in quiet mode to avoid blocking on missing prompt.
try:
while self._channel.recv_ready():
self._channel.recv(self.buffer_size)
except Exception:
pass
time.sleep(0.5)
try:
self._channel.send(b'\n')
except Exception:
pass
self._get_match_recv()
self.switch_user()
except Exception as error:
@@ -186,16 +199,40 @@ class SSHClient:
def _get_match_recv(self, answer_reg=DEFAULT_RE):
buffer_str = ''
prev_str = ''
last_change_ts = time.time()
# Quiet-mode reading only when explicitly requested, or when both
# answer regex and prompt are permissive defaults.
use_regex_match = True
if answer_reg == DEFAULT_RE and self.prompt == DEFAULT_RE:
use_regex_match = False
check_reg = self.prompt if answer_reg == DEFAULT_RE else answer_reg
while True:
if self.channel.recv_ready():
chunk = self.channel.recv(self.buffer_size).decode('utf-8', 'replace')
buffer_str += chunk
if chunk:
buffer_str += chunk
last_change_ts = time.time()
if buffer_str and buffer_str != prev_str:
if self.__match(check_reg, buffer_str):
if use_regex_match:
if self.__match(check_reg, buffer_str):
break
else:
# Wait for a brief quiet period to approximate completion
if time.time() - last_change_ts > 0.3:
break
elif not use_regex_match and buffer_str:
# In quiet mode with some data already seen, also break after
# a brief quiet window even if buffer hasn't changed this loop.
if time.time() - last_change_ts > 0.3:
break
elif not use_regex_match and not buffer_str:
# No data at all in quiet mode; bail after short wait
if time.time() - last_change_ts > 1.0:
break
prev_str = buffer_str
time.sleep(0.01)

View File

@@ -93,10 +93,10 @@ dependencies = [
'celery==5.3.1',
'flower==2.0.1',
'django-celery-beat==2.6.0',
'kombu==5.3.1',
'kombu==5.3.5',
'uvicorn==0.22.0',
'websockets==11.0.3',
'python-ldap==3.4.3',
'python-ldap==3.4.5',
'ldap3==2.9.1',
'django-radius',
'django-cas-ng',

16
uv.lock generated
View File

@@ -1,5 +1,5 @@
version = 1
revision = 2
revision = 3
requires-python = ">=3.11"
resolution-markers = [
"python_full_version >= '3.14'",
@@ -2553,7 +2553,7 @@ requires-dist = [
{ name = "itypes", specifier = "==1.2.0" },
{ name = "jinja2", specifier = "==3.1.6" },
{ name = "jsonfield2", specifier = "==4.0.0.post0" },
{ name = "kombu", specifier = "==5.3.1" },
{ name = "kombu", specifier = "==5.3.5" },
{ name = "ldap3", specifier = "==2.9.1" },
{ name = "lxml", specifier = "==5.2.1" },
{ name = "markupsafe", specifier = "==2.1.3" },
@@ -2595,7 +2595,7 @@ requires-dist = [
{ name = "python-cas", specifier = "==1.6.0" },
{ name = "python-daemon", specifier = "==3.0.1" },
{ name = "python-dateutil", specifier = "==2.8.2" },
{ name = "python-ldap", specifier = "==3.4.3" },
{ name = "python-ldap", specifier = "==3.4.5" },
{ name = "python-nmap", specifier = "==0.7.1" },
{ name = "python-redis-lock", specifier = "==4.0.0" },
{ name = "python3-saml", specifier = "==1.16.0" },
@@ -2671,15 +2671,15 @@ wheels = [
[[package]]
name = "kombu"
version = "5.3.1"
version = "5.3.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "amqp" },
{ name = "vine" },
]
sdist = { url = "https://files.pythonhosted.org/packages/c8/69/b703f8ec8d0406be22534dad885cac847fe092b793c4893034e3308feb9b/kombu-5.3.1.tar.gz", hash = "sha256:fbd7572d92c0bf71c112a6b45163153dea5a7b6a701ec16b568c27d0fd2370f2", size = 434284, upload-time = "2023-06-15T13:16:22.683Z" }
sdist = { url = "https://files.pythonhosted.org/packages/55/61/0b91085837d446570ea12f63f79463e5a74b449956b1ca9d1946a6f584c2/kombu-5.3.5.tar.gz", hash = "sha256:30e470f1a6b49c70dc6f6d13c3e4cc4e178aa6c469ceb6bcd55645385fc84b93", size = 438460, upload-time = "2024-01-12T19:55:54.982Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/63/58/b23b9c1ffb30d8b5cdfc7bdecb17bfd7ea20c619e86e515297b496177144/kombu-5.3.1-py3-none-any.whl", hash = "sha256:48ee589e8833126fd01ceaa08f8a2041334e9f5894e5763c8486a550454551e9", size = 198498, upload-time = "2023-06-15T13:16:14.57Z" },
{ url = "https://files.pythonhosted.org/packages/f7/88/daca086d72832c74a7e239558ad484644c8cda0b9ae8a690f247bf13c268/kombu-5.3.5-py3-none-any.whl", hash = "sha256:0eac1bbb464afe6fb0924b21bf79460416d25d8abc52546d4f16cad94f789488", size = 200001, upload-time = "2024-01-12T19:55:51.59Z" },
]
[[package]]
@@ -4211,13 +4211,13 @@ wheels = [
[[package]]
name = "python-ldap"
version = "3.4.3"
version = "3.4.5"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "pyasn1" },
{ name = "pyasn1-modules" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3a/7d/de9ae3e5843de77eae3a60c1e70ef5cad9960db50521e8459f7d567a1d1d/python-ldap-3.4.3.tar.gz", hash = "sha256:ab26c519a0ef2a443a2a10391fa3c5cb52d7871323399db949ebfaa9f25ee2a0", size = 377438, upload-time = "2022-09-20T15:46:21.283Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0c/88/8d2797decc42e1c1cdd926df4f005e938b0643d0d1219c08c2b5ee8ae0c0/python_ldap-3.4.5.tar.gz", hash = "sha256:b2f6ef1c37fe2c6a5a85212efe71311ee21847766a7d45fcb711f3b270a5f79a", size = 388482, upload-time = "2025-10-10T20:00:39.06Z" }
[[package]]
name = "python-nmap"