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,
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. 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, "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
# if not path.startswith('/api/v1/users'):
# return None # return None
operation = super().get_operation(path, *args, **kwargs) # # if not path.startswith('/api/v1/users'):
if not operation: # # return None
return operation # operation = super().get_operation(path, *args, **kwargs)
# if not 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]
@@ -191,6 +193,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',
'orgs_orgs_create', 'orgs_orgs_partial_update', 'orgs_orgs_create', 'orgs_orgs_partial_update',

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')
if chunk:
buffer_str += 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 use_regex_match:
if self.__match(check_reg, buffer_str): if self.__match(check_reg, buffer_str):
break 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 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"