Compare commits

...

6 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
12 changed files with 189 additions and 53 deletions

View File

@@ -603,7 +603,7 @@ class JSONManyToManyField(models.JSONField):
return None return None
if isinstance(value, RelatedManager): if isinstance(value, RelatedManager):
value = value.value value = value.value
return value return json.dumps(value)
def validate(self, value, model_instance): def validate(self, value, model_instance):
super().validate(value, model_instance) super().validate(value, model_instance)

View File

@@ -162,6 +162,7 @@ class FeiShu(RequestMixin):
except Exception as e: except Exception as e:
logger.error(f'Get user detail error: {e} data={data}') 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) info = flatten_dict(data)
default_detail = self.default_user_detail(data, user_id) default_detail = self.default_user_detail(data, user_id)
detail = map_attributes(default_detail, info, self.attributes) detail = map_attributes(default_detail, info, self.attributes)

View File

@@ -1,3 +1,5 @@
#!/usr/bin/env python3
list_params = [ list_params = [
{ {
"name": "search", "name": "search",
@@ -11,41 +13,99 @@ list_params = [
"in": "query", "in": "query",
"description": "Which field to use when ordering the results.", "description": "Which field to use when ordering the results.",
"required": False, "required": False,
"type": "string" "type": "string",
"enum": [
"name", "date_created", "date_updated", "created_by",
]
}, },
{ {
"name": "limit", "name": "limit",
"in": "query", "in": "query",
"description": "Number of results to return per page. Default is 10.", "description": "Number of results to return per page. Default is 10.",
"required": False, "required": False,
"type": "integer" "type": "integer",
"default": 10
}, },
{ {
"name": "offset", "name": "offset",
"in": "query", "in": "query",
"description": "The initial index from which to return the results.", "description": "The initial index from which to return the results.",
"required": False, "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 = [ common_params = [
{ {
"name": "resource", "name": "resource",
"in": "path", "in": "path",
"description": """Resource to query, e.g. users, assets, permissions, acls, user-groups, policies, nodes, hosts, "description": f"""Resource to query, {supported_resources}
devices, clouds, webs, databases, GET /api/v1/resources/ to get full supported resource.
gpts, ds, customs, platforms, zones, gateways, protocol-settings, labels, virtual-accounts, if you want to get the resource list, you can set the resource name in the url.
gathered-accounts, account-templates, account-template-secrets, account-backups, account-backup-executions, if you want to create a resource, you can set the resource name in the url.
change-secret-automations, change-secret-executions, change-secret-records, gather-account-automations, if you want to get the resource detail, you can set the resource name and id in the url.
gather-account-executions, push-account-automations, push-account-executions, push-account-records, if you want to update the resource, you can set the resource name and id in the url.
check-account-automations, check-account-executions, account-risks, integration-apps, asset-permissions, if you want to delete the resource, you can set the resource name and id in the url.
zones, gateways, virtual-accounts, gathered-accounts, account-templates, account-template-secrets,, """,
GET /api/v1/resources/ to get full supported resource.
""",
"required": True, "required": True,
"type": "string" "type": "string",
"enum": supported_resources
}, },
{ {
"name": "X-JMS-ORG", "name": "X-JMS-ORG",

View File

@@ -69,13 +69,13 @@ class ResourceListApi(ProxyMixin, APIView):
return self._proxy(request, resource) return self._proxy(request, resource)
@extend_schema( @extend_schema(
operation_id="create_resource_by_type", operation_id="create_resource",
summary="Create resource", summary="Create resource",
parameters=create_params, parameters=create_params,
description=""" description="""
Create resource, Create resource,
OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and helptext, and OPTIONS /api/v1/resources/{resource}/?action=post to get every resource type field type and help text,
you will know how to create it. and you will know how to create it.
""", """,
) )
def post(self, request, resource, pk=None): 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.routers import DefaultRouter
from rest_framework.views import APIView from rest_framework.views import APIView
from .const import supported_resources
from .utils import get_full_resource_map from .utils import get_full_resource_map
router = DefaultRouter() router = DefaultRouter()
@@ -35,6 +36,8 @@ class ResourceTypeListApi(APIView):
result = [] result = []
resource_map = get_full_resource_map() resource_map = get_full_resource_map()
for name, desc in resource_map.items(): for name, desc in resource_map.items():
if name not in supported_resources:
continue
desc = resource_map.get(name, {}) desc = resource_map.get(name, {})
resource = { resource = {
"name": name, "name": name,

View File

@@ -4,8 +4,9 @@ import re
from functools import lru_cache from functools import lru_cache
from typing import Dict from typing import Dict
from django.urls import URLPattern from django.utils.functional import LazyObject
from django.urls import URLResolver from django.urls import URLPattern, URLResolver
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter from drf_spectacular.utils import OpenApiParameter
from rest_framework.routers import DefaultRouter 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): 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( return OpenApiParameter(
name=d['name'], location=d['in'], name=d['name'],
description=d['description'], type=d['type'], required=d.get('required', False) location=location,
description=d['description'],
type=param_type,
required=d.get('required', False),
enum=enum
) )

View File

@@ -88,7 +88,7 @@ urlpatterns += [
path('core/jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'), path('core/jsi18n/', JavaScriptCatalog.as_view(), name='javascript-catalog'),
] ]
DOC_TTL = 60 * 60 DOC_TTL = 1
DOC_VERSION = uuid.uuid4().hex DOC_VERSION = uuid.uuid4().hex
cache_kwargs = { cache_kwargs = {
'cache_timeout': DOC_TTL, 'cache_timeout': DOC_TTL,
@@ -98,7 +98,7 @@ cache_kwargs = {
} }
# docs 路由 # docs 路由
urlpatterns += [ 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'), 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/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'), 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): class CustomAutoSchema(AutoSchema):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
self.from_mcp = kwargs.get('from_mcp', False) self.from_mcp = True
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
def map_parsers(self): def map_parsers(self):
@@ -30,19 +30,19 @@ class CustomAutoSchema(AutoSchema):
tags = ['_'.join(operation_keys[:2])] tags = ['_'.join(operation_keys[:2])]
return tags return tags
def get_operation(self, path, *args, **kwargs): # def get_operation(self, path, *args, **kwargs):
if path.endswith('render-to-json/'): # if path.endswith('render-to-json/'):
return None # return None
# if not path.startswith('/api/v1/users'): # # if not path.startswith('/api/v1/users'):
# return None # # return None
operation = super().get_operation(path, *args, **kwargs) # operation = super().get_operation(path, *args, **kwargs)
if not operation: # if not operation:
return operation # return operation
if not operation.get('summary', ''): # if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId') # operation['summary'] = operation.get('operationId')
return operation # return operation
def get_operation_id(self): def get_operation_id(self):
tokenized_path = self._tokenize_path() tokenized_path = self._tokenize_path()
@@ -118,7 +118,8 @@ class CustomAutoSchema(AutoSchema):
'change-secret-dashboard', '/copy-to-assets/', 'change-secret-dashboard', '/copy-to-assets/',
'/move-to-assets/', 'dashboard', 'index', 'countries', '/move-to-assets/', 'dashboard', 'index', 'countries',
'/resources/cache/', 'profile/mfa', 'profile/password', '/resources/cache/', 'profile/mfa', 'profile/password',
'profile/permissions', 'prometheus', 'constraints' 'profile/permissions', 'prometheus', 'constraints',
'/api/swagger.json', '/api/swagger.yaml',
] ]
for p in excludes: for p in excludes:
if path.find(p) >= 0: if path.find(p) >= 0:
@@ -132,14 +133,15 @@ class CustomAutoSchema(AutoSchema):
apps = [] apps = []
if self.from_mcp: if self.from_mcp:
apps = [ # apps = [
'ops', 'tickets', 'authentication', # 'ops', 'tickets', 'authentication',
'settings', 'xpack', 'terminal', 'rbac', # 'settings', 'xpack', 'terminal', 'rbac',
'notifications', 'promethues', 'acls' # 'notifications', 'promethues', 'acls'
] # ]
apps = ['resources']
app_name = parts[3] app_name = parts[3]
if app_name in apps: if app_name not in apps:
return True return True
models = [] models = []
model = parts[4] model = parts[4]
@@ -190,6 +192,9 @@ class CustomAutoSchema(AutoSchema):
if not operation.get('summary', ''): if not operation.get('summary', ''):
operation['summary'] = operation.get('operationId') operation['summary'] = operation.get('operationId')
if self.is_excluded():
return None
exclude_operations = [ exclude_operations = [
'orgs_orgs_read', 'orgs_orgs_update', 'orgs_orgs_delete', '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 from django.contrib.auth.mixins import LoginRequiredMixin
class SwaggerUI(LoginRequiredMixin, SpectacularSwaggerView): class SwaggerUI( SpectacularSwaggerView):
pass pass
class Redoc(LoginRequiredMixin, SpectacularRedocView): class Redoc(LoginRequiredMixin, SpectacularRedocView):
pass pass
class SchemeMixin: class SchemeMixin:
permission_classes = []
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
schema = super().get(request, *args, **kwargs).data schema = super().get(request, *args, **kwargs).data
host = request.get_host() host = request.get_host()
@@ -37,7 +38,7 @@ class SchemeMixin:
} }
return Response(schema) return Response(schema)
@method_decorator(cache_page(60 * 5,), name="dispatch") # @method_decorator(cache_page(60 * 5,), name="dispatch")
class JsonApi(SchemeMixin, SpectacularJSONAPIView): class JsonApi(SchemeMixin, SpectacularJSONAPIView):
pass pass

View File

@@ -160,6 +160,19 @@ class SSHClient:
try: try:
self.client.connect(**self.connect_params) self.client.connect(**self.connect_params)
self._channel = self.client.invoke_shell() 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._get_match_recv()
self.switch_user() self.switch_user()
except Exception as error: except Exception as error:
@@ -186,16 +199,40 @@ class SSHClient:
def _get_match_recv(self, answer_reg=DEFAULT_RE): def _get_match_recv(self, answer_reg=DEFAULT_RE):
buffer_str = '' buffer_str = ''
prev_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 check_reg = self.prompt if answer_reg == DEFAULT_RE else answer_reg
while True: while True:
if self.channel.recv_ready(): if self.channel.recv_ready():
chunk = self.channel.recv(self.buffer_size).decode('utf-8', 'replace') 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 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 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 prev_str = buffer_str
time.sleep(0.01) time.sleep(0.01)

View File

@@ -68,14 +68,14 @@ dependencies = [
'ipip-ipdb==1.6.1', 'ipip-ipdb==1.6.1',
'pywinrm==0.4.3', 'pywinrm==0.4.3',
'python-nmap==0.7.1', 'python-nmap==0.7.1',
'django==4.2.24', 'django==4.1.13',
'django-bootstrap3==23.4', 'django-bootstrap3==23.4',
'django-filter==23.2', 'django-filter==23.2',
'django-formtools==2.5.1', 'django-formtools==2.5.1',
'django-ranged-response==0.2.0', 'django-ranged-response==0.2.0',
'django-simple-captcha==0.5.18', 'django-simple-captcha==0.5.18',
'django-timezone-field==5.1', 'django-timezone-field==5.1',
'djangorestframework==3.16.1', 'djangorestframework==3.14.0',
'djangorestframework-bulk==0.2.1', 'djangorestframework-bulk==0.2.1',
'django-simple-history==3.6.0', 'django-simple-history==3.6.0',
'django-private-storage==3.1', 'django-private-storage==3.1',

6
uv.lock generated
View File

@@ -2595,7 +2595,7 @@ requires-dist = [
{ name = "python-cas", specifier = "==1.6.0" }, { name = "python-cas", specifier = "==1.6.0" },
{ name = "python-daemon", specifier = "==3.0.1" }, { name = "python-daemon", specifier = "==3.0.1" },
{ name = "python-dateutil", specifier = "==2.8.2" }, { 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-nmap", specifier = "==0.7.1" },
{ name = "python-redis-lock", specifier = "==4.0.0" }, { name = "python-redis-lock", specifier = "==4.0.0" },
{ name = "python3-saml", specifier = "==1.16.0" }, { name = "python3-saml", specifier = "==1.16.0" },
@@ -4211,13 +4211,13 @@ wheels = [
[[package]] [[package]]
name = "python-ldap" name = "python-ldap"
version = "3.4.3" version = "3.4.5"
source = { registry = "https://pypi.org/simple" } source = { registry = "https://pypi.org/simple" }
dependencies = [ dependencies = [
{ name = "pyasn1" }, { name = "pyasn1" },
{ name = "pyasn1-modules" }, { 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]] [[package]]
name = "python-nmap" name = "python-nmap"