WIP-CHECKPOINT[auth-refactor]: 固化工程师崩溃前部分成果 + 同树其他未提交WIP(仅源码,不含密钥/二进制)-- 待重激活工程师续作

This commit is contained in:
Simon
2026-07-07 21:52:11 +08:00
parent 242c1967ff
commit fab75760e0
203 changed files with 21504 additions and 3345 deletions
+9
View File
@@ -0,0 +1,9 @@
# =============================================================================
# 企微IT智能服务台 — 阶段5 自动化 核心模块包
# =============================================================================
# 说明:存放自动化引擎的「基础设施层」代码(外部客户端、审计、重试等)。
# 与 app/integrations/* 的区别:
# - app/integrations/* 由管理后台在 system_configs 配置,供管理端功能使用;
# - app/core/clients/* 由环境变量 AUTOMATION_* 配置,供自动化闭环引擎使用,
# 未配置时返回 None,引擎自动降级(关键词兜底 / EHR 兜底 / 转人工)。
# =============================================================================
+289
View File
@@ -0,0 +1,289 @@
# =============================================================================
# 企微IT智能服务台 — 阶段5 自动化 外部客户端基类
# =============================================================================
# 说明:定义自动化引擎所有外部系统客户端的统一基类 BaseClient 与异常体系。
#
# 设计要点:
# 1. 异步:基于 httpx.AsyncClient,所有方法均为 coroutine。
# 2. 超时:统一超时(默认 30s),避免单点阻塞。
# 3. 重试:优先使用 tenacity 做指数退避重试(仅对网络层超时/连接错误重试);
# 若运行环境未安装 tenacity,则降级为单次直连(结构不变,便于单元测试 mock)。
# 4. 审计:可选 audit 回调,记录出入参、状态、耗时,供 ActionLog 落表。
# 5. 异常:统一抛出 BaseClientError 子类,executor 捕获后转 EXTERNAL_CALL_FAILED。
#
# 统一 _request 骨架:
# async _request(method, path, *, params=None, json=None, headers=None) -> dict
# =============================================================================
from __future__ import annotations
import json
import logging
import time
from typing import Any, Awaitable, Callable, Dict, Optional
import httpx
# tenacity 为可选依赖:未安装时降级为单次直连,不影响结构与可测性。
try: # pragma: no cover - 依赖可选
from tenacity import (
AsyncRetrying,
retry_if_exception_type,
stop_after_attempt,
wait_exponential,
)
_HAS_TENACITY = True
except Exception: # noqa: BLE001 pragma: no cover
_HAS_TENACITY = False
logger = logging.getLogger(__name__)
# 审计回调签名:接收关键字参数,落 ActionLog 表。
AuditFn = Optional[Callable[..., Awaitable[None]]]
# --------------------------------------------------------------------------
# 异常体系
# --------------------------------------------------------------------------
class BaseClientError(Exception):
"""外部客户端统一异常基类。
Attributes:
code: 细分子错误码(自动化 ActionLog 记录用)
message: 错误消息
data: 附加数据
"""
code: int = 5000
def __init__(self, message: str = "", code: int = 0, data: Any = None):
self.code = code or self.code
self.message = message or self.__class__.__name__
self.data = data
super().__init__(self.message)
class ClientConfigError(BaseClientError):
"""客户端配置缺失(密钥/基址未配置)。"""
code = 5001
class ClientConnectionError(BaseClientError):
"""网络层错误(超时 / 连接失败)。"""
code = 5002
class ClientAuthError(BaseClientError):
"""认证/鉴权失败(401 / 签名无效 / Token 失效)。"""
code = 5003
class ClientAPIError(BaseClientError):
"""外部系统返回业务错误。"""
code = 5004
def __init__(self, message: str = "", code: int = 0, status: int = 0, data: Any = None):
self.status = status
super().__init__(message, code, data)
# --------------------------------------------------------------------------
# 基类
# --------------------------------------------------------------------------
class BaseClient:
"""外部系统客户端统一基类。
子类只需实现具体接口的签名/入参,并通过 self._request(...) 发送请求;
统一的超时、重试、审计、异常处理由基类完成。
Attributes:
system: 外部系统标识(huorong/lianruan/dify/ragflow/ehr),用于审计。
base_url: 基址(不含尾部斜杠)。
timeout: 请求超时(秒)。
audit: 可选审计回调。
max_retries: 网络层最大重试次数(仅 tenacity 可用时生效)。
"""
# 子类覆盖:外部系统标识
system: str = "internal"
def __init__(
self,
*,
base_url: str,
timeout: float = 30.0,
audit: AuditFn = None,
max_retries: int = 2,
):
self.base_url = (base_url or "").rstrip("/")
self.timeout = timeout
self.audit = audit
self.max_retries = max_retries
self._client: Optional[httpx.AsyncClient] = None
# ----------------------------------------------------------------------
# 连接池管理
# ----------------------------------------------------------------------
async def _get_client(self) -> httpx.AsyncClient:
"""获取/复用 httpx 异步客户端(懒初始化)。"""
if self._client is None or self._client.is_closed:
self._client = httpx.AsyncClient(timeout=self.timeout)
return self._client
async def close(self) -> None:
"""关闭连接池,释放资源。"""
if self._client is not None and not self._client.is_closed:
await self._client.aclose()
self._client = None
# ----------------------------------------------------------------------
# 审计记录(出入参全量,脱敏截断)
# ----------------------------------------------------------------------
@staticmethod
def _safe(obj: Any, limit: int = 4000) -> Any:
"""将出入参转为可序列化字符串(截断,避免审计过大)。"""
try:
s = json.dumps(obj, ensure_ascii=False, default=str)
except Exception: # noqa: BLE001
s = str(obj)
return s if len(s) <= limit else s[:limit] + "...(truncated)"
async def _audit(
self,
event: str,
direction: str,
request: Any,
response: Any,
status: str,
latency_ms: int,
error: Optional[str] = None,
) -> None:
"""记录一次调用的审计信息(audit 回调为空时跳过)。"""
if self.audit is None:
return
try:
await self.audit(
event=event,
direction=direction,
system=self.system,
request=self._safe(request),
response=self._safe(response),
status=status,
latency_ms=latency_ms,
error=error,
)
except Exception: # noqa: BLE001 审计失败不应影响主流程
logger.debug(f"审计回调异常 system={self.system} event={event}")
# ----------------------------------------------------------------------
# 网络层发送(含 tenacity 重试)
# ----------------------------------------------------------------------
async def _send_with_retry(
self,
client: httpx.AsyncClient,
method: str,
url: str,
*,
params: Optional[Dict[str, Any]],
json_body: Optional[Dict[str, Any]],
headers: Optional[Dict[str, str]],
) -> httpx.Response:
"""发送 HTTP 请求(网络层错误时按 max_retries 重试)。"""
if _HAS_TENACITY:
async for attempt in AsyncRetrying(
stop=stop_after_attempt(self.max_retries + 1),
wait=wait_exponential(multiplier=0.5, min=0.5, max=3),
retry=retry_if_exception_type((httpx.TimeoutException, httpx.ConnectError)),
reraise=True,
):
with attempt:
return await client.request(
method, url, params=params, json=json_body, headers=headers
)
# 无 tenacity 时单次直连
return await client.request(
method, url, params=params, json=json_body, headers=headers
)
# ----------------------------------------------------------------------
# 统一请求骨架
# ----------------------------------------------------------------------
async def _request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
headers: Optional[Dict[str, str]] = None,
timeout: Optional[float] = None,
) -> Dict[str, Any]:
"""统一请求入口:超时 + 重试 + 审计 + 异常归一。
Args:
method: HTTP 方法(GET/POST/...
path: 接口路径(如 /api/clnts/_list
params: query 参数
json_body: JSON 请求体
headers: 额外请求头
timeout: 覆盖默认超时
Returns:
Dict: 解析后的 JSON 响应(统一为 dict)
Raises:
ClientConnectionError: 网络层错误
ClientAPIError: 业务错误(由子类在覆盖方法中抛出)
"""
if not self.base_url:
raise ClientConfigError(f"{self.system} 未配置 base_url")
url = f"{self.base_url}{path}"
event = f"{self.system}.{path.strip('/').replace('/', '.') or 'root'}"
request_payload = json_body if json_body is not None else params
start = time.monotonic()
# 临时调整超时(仅本次请求)
saved_timeout = None
client = await self._get_client()
if timeout is not None and client.timeout is not None:
saved_timeout = client.timeout
client.timeout = httpx.Timeout(timeout)
try:
response = await self._send_with_retry(
client, method, url, params=params, json_body=json_body, headers=headers
)
latency = int((time.monotonic() - start) * 1000)
data: Dict[str, Any] = response.json() if response.content else {}
await self._audit(event, "out", request_payload, data, "success", latency)
return data
except (ClientConfigError, ClientConnectionError, ClientAuthError, ClientAPIError):
latency = int((time.monotonic() - start) * 1000)
await self._audit(event, "out", request_payload, None, "error", latency,
error="client_error")
raise
except (httpx.TimeoutException, httpx.ConnectError) as e:
latency = int((time.monotonic() - start) * 1000)
await self._audit(event, "out", request_payload, None, "error", latency,
error=str(e))
raise ClientConnectionError(f"{self.system} 网络错误: {e}")
except Exception as e: # noqa: BLE001
latency = int((time.monotonic() - start) * 1000)
await self._audit(event, "out", request_payload, None, "error", latency,
error=str(e))
raise ClientConnectionError(f"{self.system} 请求异常: {e}")
finally:
if saved_timeout is not None:
client.timeout = saved_timeout
# ----------------------------------------------------------------------
# 连接测试(子类可覆盖)
# ----------------------------------------------------------------------
async def test_connection(self) -> Dict[str, Any]:
"""默认连接测试:子类应覆盖为各自的轻量探测。"""
return {"success": True, "message": f"{self.system} 客户端已配置"}
+247
View File
@@ -0,0 +1,247 @@
# =============================================================================
# 企微IT智能服务台 — 阶段5 自动化 火绒终端安全客户端
# =============================================================================
# 说明:自动化引擎使用的火绒客户端(环境变量 AUTOMATION_HUORONG_* 驱动)。
# 与 app/integrations/huorong(管理后台 DB 配置)区分:本客户端由自动化引擎
# 专用,未配置时 get_huorong_client() 返回 None,引擎降级转人工。
#
# 签名算法(火绒官方 HRESS Authorization Header):
# Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature
# Signature = urlencode(base64(hmac-sha1(AccessKeySecret,
# AccessKeyId + "\n" + Expires + "\n" + METHOD + "\n"
# + Content-MD5 + "\n" + CanonicalizedResource)))
#
# 自动化动作适配器调用的方法(方法签名必须与 action_registry/rollback 一致):
# - create_scan_task(client_ids, scan_type) 病毒扫描(low
# - isolate_terminal(client_ids) 终端隔离(high,需审批)
# - unisolate_terminal(client_ids) 解除隔离(回滚补偿)
# 审计/查询方法(mapping/排障用):
# - list_terminals / get_terminal_detail / list_terminal_leaks(_leak)
# - get_virus_events(_virus_events) / send_notification
# =============================================================================
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import logging
import time
from typing import Any, Dict, List, Optional
from urllib.parse import quote
from app.config import settings
from app.core.clients.base import (
BaseClient,
BaseClientError,
ClientAPIError,
ClientAuthError,
ClientConfigError,
)
logger = logging.getLogger(__name__)
# 签名有效期(秒)
_SIGN_EXPIRES_SECONDS = 300
# 默认请求超时(秒)
_DEFAULT_TIMEOUT = 10.0
# 默认分页大小
_DEFAULT_PAGE_SIZE = 20
class HuorongClient(BaseClient):
"""火绒终端安全客户端(自动化引擎专用)。
使用 HRESS HMAC-SHA1 签名,POST JSON 调用火绒 API。
"""
system = "huorong"
def __init__(
self,
*,
access_key_id: str,
access_key_secret: str,
base_url: str,
timeout: float = _DEFAULT_TIMEOUT,
audit: Any = None,
max_retries: int = 2,
):
if not access_key_id or not access_key_secret:
raise ClientConfigError("火绒 AccessKey ID / Secret 未配置")
if not base_url:
raise ClientConfigError("火绒 base_url 未配置")
super().__init__(base_url=base_url, timeout=timeout, audit=audit, max_retries=max_retries)
self.access_key_id = access_key_id
self.access_key_secret = access_key_secret
# ======================================================================
# 签名
# ======================================================================
def _compute_content_md5(self, body_bytes: bytes) -> str:
"""计算请求体 Content-MD5RFC2616: MD5 二进制摘要 → base64)。"""
return base64.b64encode(hashlib.md5(body_bytes).digest()).decode("utf-8")
def _sign_request(self, method: str, path: str, body_bytes: bytes) -> Dict[str, str]:
"""生成 HRESS Authorization Header 签名。"""
expires = str(int(time.time()) + _SIGN_EXPIRES_SECONDS)
content_md5 = self._compute_content_md5(body_bytes) if body_bytes else ""
canonicalized_resource = path.lstrip("/")
string_to_sign = (
self.access_key_id + "\n"
+ expires + "\n"
+ method + "\n"
+ content_md5 + "\n"
+ canonicalized_resource
)
signature_raw = hmac.new(
self.access_key_secret.encode("utf-8"),
string_to_sign.encode("utf-8"),
hashlib.sha1,
).digest()
signature_b64 = base64.b64encode(signature_raw).decode("utf-8")
signature_encoded = quote(signature_b64, safe="")
authorization = f"HRESS{self.access_key_id}:{expires}:{signature_encoded}"
return {
"Authorization": authorization,
"Content-Type": "application/json; charset=utf-8",
}
async def _post(self, path: str, body: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
"""火绒统一 POST(带签名 + 业务错误码处理)。"""
body_bytes = json.dumps(body or {}, separators=(",", ":")).encode("utf-8")
headers = self._sign_request("POST", path, body_bytes)
try:
data = await self._request("POST", path, json_body=body or {}, headers=headers)
except BaseClientError:
raise
# 火绒业务错误码:errno=0 成功
errcode = data.get("errno", data.get("errcode", 0))
if errcode != 0:
msg = data.get("errmsg", data.get("msg", "未知错误"))
if errcode in (1, 401, 403):
raise ClientAuthError(f"火绒认证/权限失败: {msg}")
raise ClientAPIError(message=f"火绒业务错误: {msg}", status=errcode, data=data)
return data
# ======================================================================
# 查询能力(_leak / _virus_events
# ======================================================================
async def list_terminals(
self, page: int = 1, per_page: int = _DEFAULT_PAGE_SIZE
) -> Dict[str, Any]:
"""查询终端列表。POST /api/clnts/_list。"""
limit = min(per_page, 200)
body = {"limit": limit, "offset": (page - 1) * limit}
resp = await self._post("/api/clnts/_list", body)
data = resp.get("data", {}) or {}
return {"total": data.get("total", 0), "items": data.get("list", [])}
async def get_terminal_detail(self, client_id: str) -> Dict[str, Any]:
"""查询终端详情。POST /api/clnts/_info2。"""
resp = await self._post("/api/clnts/_info2", {"client_id": client_id})
return resp.get("data", {}) or {}
async def list_terminal_leaks(
self, page: int = 1, per_page: int = _DEFAULT_PAGE_SIZE
) -> Dict[str, Any]:
"""查询高危漏洞终端(_leak)。POST /api/clnts/_leak。"""
limit = min(per_page, 200)
body = {"limit": limit, "offset": (page - 1) * limit}
resp = await self._post("/api/clnts/_leak", body)
data = resp.get("data", {}) or {}
return {
"total": data.get("risk_client", 0),
"all_client": data.get("all_client", 0),
"risk_client": data.get("risk_client", 0),
"items": data.get("list", []),
}
async def get_virus_events(
self,
query_type: int = 2,
client_id: Optional[str] = None,
group_id: Optional[str] = None,
page: int = 1,
per_page: int = _DEFAULT_PAGE_SIZE,
) -> Dict[str, Any]:
"""查询病毒事件(_virus_events)。POST /api/clnts/_virus_events。"""
limit = min(per_page, 200)
body: Dict[str, Any] = {"type": query_type, "limit": limit, "offset": (page - 1) * limit}
if query_type == 0 and client_id:
body["client_id"] = client_id
if query_type in (0, 1) and group_id:
body["group_id"] = int(group_id)
resp = await self._post("/api/clnts/_virus_events", body)
data = resp.get("data", {}) or {}
return {"total": data.get("total", 0), "items": data.get("list", [])}
# ======================================================================
# 控制能力(自动化动作适配器调用)
# ======================================================================
async def create_scan_task(
self, client_ids: List[str], scan_type: str = "quick_scan"
) -> Dict[str, Any]:
"""创建病毒扫描任务(low,自动执行)。POST /api/task/_create。"""
body = {"type": scan_type, "clients": client_ids}
resp = await self._post("/api/task/_create", body)
logger.info(f"火绒扫描任务: type={scan_type}, client_ids={client_ids}")
return resp.get("data", {}) or {}
async def isolate_terminal(self, client_ids: List[str]) -> Dict[str, Any]:
"""隔离终端(断网,high,需审批)。POST /api/task/_create netctrl。"""
body = {"type": "netctrl", "net_isolation": True, "clients": client_ids}
resp = await self._post("/api/task/_create", body)
logger.warning(f"火绒终端隔离: client_ids={client_ids}")
return resp.get("data", {}) or {}
async def unisolate_terminal(self, client_ids: List[str]) -> Dict[str, Any]:
"""解除终端隔离(回滚补偿)。POST /api/task/_create netctrl。"""
body = {"type": "netctrl", "net_isolation": False, "clients": client_ids}
resp = await self._post("/api/task/_create", body)
logger.info(f"火绒解除隔离: client_ids={client_ids}")
return resp.get("data", {}) or {}
async def send_notification(self, client_ids: List[str], content: str) -> Dict[str, Any]:
"""向终端推送通知。POST /api/task/_create message。"""
body = {"type": "message", "clients": client_ids, "content": content}
resp = await self._post("/api/task/_create", body)
return resp.get("data", {}) or {}
# ======================================================================
# 连接测试
# ======================================================================
async def test_connection(self) -> Dict[str, Any]:
"""轻量连接测试(_list 单条)。"""
try:
result = await self.list_terminals(page=1, per_page=1)
return {"success": True, "message": f"连接成功,共 {result.get('total', 0)} 终端"}
except BaseClientError as e:
return {"success": False, "message": e.message}
async def get_huorong_client(audit: Any = None) -> Optional[HuorongClient]:
"""构建火绒客户端(环境变量 AUTOMATION_HUORONG_* 驱动)。
任一必填项为空 → 返回 None(引擎降级转人工,不抛异常)。
Returns:
Optional[HuorongClient]: 配置完整时返回客户端,否则 None。
"""
base_url = getattr(settings, "automation_huorong_base_url", "") or ""
access_key_id = getattr(settings, "automation_huorong_access_key_id", "") or ""
access_key_secret = getattr(settings, "automation_huorong_access_key_secret", "") or ""
if not (base_url and access_key_id and access_key_secret):
logger.debug("火绒未配置(AUTOMATION_HUORONG_*),返回 None")
return None
try:
return HuorongClient(
access_key_id=access_key_id,
access_key_secret=access_key_secret,
base_url=base_url,
audit=audit,
)
except ClientConfigError as e:
logger.warning(f"火绒客户端构建失败: {e.message}")
return None