mirror of
https://github.com/jumpserver/jumpserver.git
synced 2025-12-15 08:32:48 +00:00
Compare commits
16 Commits
origin/dev
...
debug_mcp
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
777b655e90 | ||
|
|
6e1736c1a7 | ||
|
|
fe31eb0a44 | ||
|
|
3e93034fbc | ||
|
|
f4b3a7d73a | ||
|
|
3781c40179 | ||
|
|
fab6219cea | ||
|
|
dd0cacb4bc | ||
|
|
b8639601a1 | ||
|
|
ab9882c9c1 | ||
|
|
77a7b74b15 | ||
|
|
4bc05865f1 | ||
|
|
bec9e4f3a7 | ||
|
|
359adf3dbb | ||
|
|
ac54bb672c | ||
|
|
9e3ba00bc4 |
2
.github/workflows/cleanup-branches.yml
vendored
2
.github/workflows/cleanup-branches.yml
vendored
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -732,6 +732,9 @@ class Config(dict):
|
||||
|
||||
# Suggestion api
|
||||
'SUGGESTION_LIMIT': 10,
|
||||
|
||||
# MCP
|
||||
'MCP_ENABLED': False,
|
||||
}
|
||||
|
||||
old_config_map = {
|
||||
|
||||
@@ -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
|
||||
@@ -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',
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
16
uv.lock
generated
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user