# ============================================================================= # 企微IT智能服务台 — 火绒终端安全 API 客户端 # ============================================================================= # 说明:封装火绒API的签名、请求、响应处理 # 核心功能: # 1. HRESS 签名实现(Authorization Header 方式) # 2. 统一请求封装(超时、重试、异常处理) # 3. P0 接口:终端列表 _list / 终端详情 _info2 / 高危漏洞 _leak / 病毒事件 _virus_events # 4. P1 接口:终端隔离/解除 _create(netctrl) / 快速扫描 / 在线终端查询 # 签名算法(来自火绒官方API文档 v1): # Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature # Signature = urlencode(base64(hmac-sha1(AccessKeySecret, # AccessKeyId + "\n" + Expires + "\n" + HTTP-METHOD + "\n" # + Content-MD5 + "\n" + CanonicalizedResource))) # CanonicalizedResource = API路径(无前导/),含排序后的查询参数 # Content-MD5 = base64(md5_digest(body_bytes)) — RFC2616 # 使用方式: # client = HuorongClient(access_key_id="...", access_key_secret="...", base_url="...") # terminals = await client.list_terminals() # ============================================================================= import base64 import hashlib import hmac import json import logging import time from typing import Any, Dict, List, Optional from urllib.parse import quote import httpx from .exceptions import ( HuorongApiError, HuorongAuthError, HuorongConnectionError, HuorongError, ) from .models import ( HuorongApiResponse, TerminalBasicInfo, TerminalDetailV2, TerminalLeakInfo, VirusEventStats, VirusHandleResult, ) logger = logging.getLogger(__name__) # 默认请求超时(秒)— 火绒内网响应通常在1秒内,3秒足够兜底 # 注意:_virus_events 查询全部终端时可能较慢,需要更长超时 DEFAULT_TIMEOUT = 10.0 # 默认分页大小 DEFAULT_PAGE_SIZE = 20 # 签名有效期(秒)— 请求签名中的 Expires 字段 SIGN_EXPIRES_SECONDS = 300 class HuorongClient: """火绒终端安全 API 客户端。 封装了火绒API的签名认证、请求发送和响应解析。 所有方法均为异步(async),使用 httpx.AsyncClient 发送请求。 签名方式:HRESS Authorization Header 参考:火绒终端安全管理系统API说明文档 v1 Attributes: access_key_id: 火绒 AccessKey ID(控制中心显示为 Secret ID) access_key_secret: 火绒 AccessKey Secret(控制中心显示为 Secret Key) base_url: 火绒API内网地址(如 http://huorong.oa.servyou-it.com:8080) timeout: 请求超时秒数 """ def __init__( self, access_key_id: str, access_key_secret: str, base_url: str, timeout: float = DEFAULT_TIMEOUT, ): """初始化火绒API客户端。 Args: access_key_id: 火绒 AccessKey ID(控制中心显示为 Secret ID) access_key_secret: 火绒 AccessKey Secret(控制中心显示为 Secret Key) base_url: 火绒API内网地址(不含尾部斜杠) timeout: 请求超时秒数,默认3秒 """ self.access_key_id = access_key_id self.access_key_secret = access_key_secret # 确保 base_url 不以 / 结尾,拼接路径时统一加 / self.base_url = base_url.rstrip("/") self.timeout = timeout # ====================================================================== # 签名实现(HRESS Authorization Header 方式) # ====================================================================== def _compute_content_md5(self, body_bytes: bytes) -> str: """计算请求体的 Content-MD5(RFC2616)。 算法步骤: 1. 计算请求体的 MD5 二进制摘要(128位) 2. 对二进制摘要进行 base64 编码 注意:不是对32位十六进制字符串编码,而是对原始二进制摘要编码。 Args: body_bytes: 请求体的字节内容 Returns: str: base64 编码的 MD5 摘要 """ md5_digest = hashlib.md5(body_bytes).digest() return base64.b64encode(md5_digest).decode("utf-8") def _build_canonicalized_resource(self, path: str) -> str: """构建 CanonicalizedResource。 根据火绒API文档: 1. 将 CanonicalizedResource 置为空字符串 2. 设置要访问的资源路径(去掉前导 /),如 "api/clnts/_list" 3. 如果请求包含子资源(查询参数),按字典序排列, 以 & 为分隔符生成子资源字符串,末尾添加 ? 和子资源字符串 示例: - /api/clnts/_list → "api/clnts/_list" - /api/group/_info?group_id=1 → "api/group/_info?group_id=1" Args: path: 请求路径(如 /api/clnts/_list) Returns: str: CanonicalizedResource 字符串 """ # 去掉前导 / return path.lstrip("/") def _sign_request( self, method: str, path: str, body_bytes: bytes = b"", ) -> Dict[str, str]: """生成火绒API请求签名(HRESS Authorization Header 方式)。 签名算法(来自火绒官方API文档): ┌─────────────────────────────────────────────────────────────────┐ │ Authorization = "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature │ │ │ │ Signature = urlencode(base64(hmac-sha1(AccessKeySecret, │ │ AccessKeyId + "\\n" │ │ + Expires + "\\n" │ │ + HTTP-METHOD + "\\n" │ │ + Content-MD5 + "\\n" │ │ + CanonicalizedResource))) │ └─────────────────────────────────────────────────────────────────┘ 其中: - AccessKeyId: 标识用户身份 - Expires: Unix 时间戳,签名过期时间(当前时间 + 300秒) - HTTP-METHOD: POST(火绒API统一使用 POST) - Content-MD5: 请求体的 RFC2616 MD5(base64编码) - CanonicalizedResource: API资源路径(去掉前导/) Args: method: HTTP方法(POST) path: 请求路径(如 /api/clnts/_list) body_bytes: 请求体字节内容 Returns: Dict[str, str]: 包含 Authorization 和 Content-Type 的 Header 字典 """ # 1. 计算过期时间(Unix时间戳) expires = str(int(time.time()) + SIGN_EXPIRES_SECONDS) # 2. 计算 Content-MD5(RFC2616: MD5 二进制摘要 → base64) content_md5 = self._compute_content_md5(body_bytes) if body_bytes else "" # 3. 构建 CanonicalizedResource(去掉前导 /) canonicalized_resource = self._build_canonicalized_resource(path) # 4. 构建签名字符串 string_to_sign = ( self.access_key_id + "\n" + expires + "\n" + method + "\n" + content_md5 + "\n" + canonicalized_resource ) # 5. HMAC-SHA1 签名 → base64 编码 → URL 编码 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="") # 6. 拼接 Authorization Header # 格式: "HRESS" + AccessKeyId + ":" + Expires + ":" + Signature authorization = f"HRESS{self.access_key_id}:{expires}:{signature_encoded}" return { "Authorization": authorization, "Content-Type": "application/json; charset=utf-8", } # ====================================================================== # 通用请求方法 # ====================================================================== async def _request( self, path: str, body: Optional[Dict[str, Any]] = None, ) -> HuorongApiResponse: """发送签名请求到火绒API。 统一处理: 1. HRESS 签名 Authorization Header 生成 2. HTTP请求发送(POST,超时控制) 3. 响应解析和错误码处理 4. 异常分类(认证/连接/API业务错误) Args: path: API路径(如 /api/clnts/_list) body: 请求体字典(可选) Returns: HuorongApiResponse: 火绒API响应 Raises: HuorongConnectionError: 网络不通或超时 HuorongAuthError: 签名验证失败 HuorongApiError: 火绒API返回业务错误 """ # 构建完整URL url = f"{self.base_url}{path}" # 序列化请求体为字节(签名基于字节内容) body_bytes = json.dumps(body, separators=(",", ":")).encode("utf-8") if body else b"{}" # 生成签名 Header headers = self._sign_request("POST", path, body_bytes) try: async with httpx.AsyncClient(timeout=self.timeout) as client: logger.debug( f"火绒API请求: POST {url}\n" f" AccessKeyID: {self.access_key_id}\n" f" Path: {path}\n" f" Body: {body_bytes[:200].decode('utf-8', errors='replace')}\n" f" Authorization: {headers.get('Authorization', 'N/A')[:60]}..." ) response = await client.post(url, headers=headers, content=body_bytes) # HTTP层面错误 if response.status_code == 401: raise HuorongAuthError() if response.status_code != 200: raise HuorongApiError( code=response.status_code, message=f"HTTP {response.status_code}: {response.text[:200]}", ) # 解析JSON响应 resp_data = response.json() api_resp = HuorongApiResponse(**resp_data) # 火绒业务错误码处理 # 官方文档定义的错误码: # - errno=0: 成功 # - errno=1: 认证失败 # - errno=2: 参数错误 # - errno=3: 服务器内部错误 # - errno=4: API未授权 if api_resp.errcode != 0: if api_resp.errcode == 1 or api_resp.errcode in (401, 403): raise HuorongAuthError(f"认证/权限失败: {api_resp.errmsg}") if api_resp.errcode == 4: raise HuorongApiError( code=api_resp.errcode, message=f"API未授权: {api_resp.errmsg}", ) raise HuorongApiError( code=api_resp.errcode, message=api_resp.errmsg, ) return api_resp except httpx.TimeoutException: raise HuorongConnectionError(f"火绒API请求超时({self.timeout}秒): {url}") except httpx.ConnectError: raise HuorongConnectionError(f"无法连接火绒服务器: {url}") except (HuorongAuthError, HuorongApiError, HuorongConnectionError): # 已分类异常,直接向上抛出 raise except Exception as e: # 未预期异常,包装为通用错误 logger.error(f"火绒API未预期异常: {type(e).__name__}: {e}") raise HuorongError(code=-1, message=f"火绒API调用异常: {e}") # ====================================================================== # P0 接口:查询能力 # ====================================================================== async def list_terminals( self, group_id: Optional[str] = None, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE, ) -> Dict[str, Any]: """查询终端基本信息列表。 火绒API: POST /api/clnts/_list 官方参数: limit(每页条数, 默认15, 最大200) + offset(起始索引, 默认0) 本方法将 page/per_page 转换为 limit/offset,保持外部接口一致。 Args: group_id: 分组ID(可选,不传则查全部分组) page: 页码(从1开始,内部转换为offset) per_page: 每页条数(内部转换为limit) Returns: Dict: 包含 total(总数) 和 items(TerminalBasicInfo列表) """ # 火绒API使用 limit/offset 分页,不是 page/per_page limit = min(per_page, 200) # 火绒限制最大200 offset = (page - 1) * limit body: Dict[str, Any] = { "limit": limit, "offset": offset, } if group_id: body["group_id"] = int(group_id) resp = await self._request("/api/clnts/_list", body) # 解析响应数据 data = resp.data or {} raw_items = data.get("list", []) total = data.get("total", 0) items = [TerminalBasicInfo(**item) for item in raw_items] return { "total": total, "items": items, } async def get_terminal_detail( self, client_id: str, optional_fields: Optional[List[str]] = None, ) -> TerminalDetailV2: """获取终端详细信息v2。 火绒API: POST /api/clnts/_info2 用途:获取终端硬件/软件/资产/网络配置等详细信息 Args: client_id: 终端唯一ID optional_fields: 需要返回的可选信息块 可选值: hardware, software, assets, netconf 默认全部返回 Returns: TerminalDetailV2: 终端详细信息 """ if optional_fields is None: optional_fields = ["hardware", "software", "assets", "netconf"] body = { "client_id": client_id, "optional_fields": optional_fields, } resp = await self._request("/api/clnts/_info2", body) data = resp.data or {} return TerminalDetailV2(**data) async def list_terminal_leaks( self, group_id: Optional[str] = None, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE, ) -> Dict[str, Any]: """查询存在高危漏洞未修复的终端。 火绒API: POST /api/clnts/_leak 官方参数: limit(每页条数, 默认15, 最大200) + offset(起始索引, 默认0) 说明:返回的是"存在高危漏洞的终端列表",不是漏洞详情。 每条记录是终端信息,字段名与 _list 不同: - cid (非 client_id) - hostname (非 computer_name) - ip_addr (非 local_ip) - stat (1=离线,2=在线,3=异常,非 is_online 布尔值) 外层有 all_client(终端总数)和 risk_client(高危终端数)统计。 Args: group_id: 分组ID(可选,不传则查全部分组) page: 页码(从1开始,内部转换为offset) per_page: 每页条数(内部转换为limit) Returns: Dict: 包含 total(高危终端总数), risk_client(高危终端数), all_client(全部终端数) 和 items(TerminalLeakInfo列表) """ # 火绒API使用 limit/offset 分页 limit = min(per_page, 200) offset = (page - 1) * limit body: Dict[str, Any] = { "limit": limit, "offset": offset, } if group_id: body["group_id"] = int(group_id) resp = await self._request("/api/clnts/_leak", body) # 解析响应数据 data = resp.data or {} raw_items = data.get("list", []) # _leak 不返回 total,但有 all_client 和 risk_client 统计 all_client = data.get("all_client", 0) risk_client = data.get("risk_client", 0) items = [TerminalLeakInfo(**item) for item in raw_items] return { "total": risk_client, # 高危终端总数 = risk_client "all_client": all_client, # 全部终端数 "risk_client": risk_client, # 高危终端数 "items": items, } async def get_virus_events( self, client_id: Optional[str] = None, group_id: Optional[str] = None, query_type: int = 2, begin_time: Optional[int] = None, end_time: Optional[int] = None, page: int = 1, per_page: int = DEFAULT_PAGE_SIZE, ) -> Dict[str, Any]: """查询终端病毒事件统计。 火绒API: POST /api/clnts/_virus_events 官方参数: - type: 查询类型(必填) 0=使用终端唯一标识查询(client_id字段必填) 1=使用分组ID查询(group_id字段必填) 2=查询全部终端日志(client_id和group_id字段可忽略) - client_id: 终端唯一标识 - group_id: 分组ID - begin_time/end_time: 日志范围时间(Unix时间戳,默认全部时间) - limit/offset: 分页参数 说明:返回终端维度的病毒日志统计,含 count(总数) 和 result{success/fail/ignored/trusted}(处理结果明细)。 Args: client_id: 终端唯一ID(type=0时必填) group_id: 分组ID(type=1时必填) query_type: 查询类型,默认2(查全部) begin_time: 日志开始时间(Unix时间戳,可选) end_time: 日志结束时间(Unix时间戳,可选) page: 页码(从1开始,内部转换为offset) per_page: 每页条数(内部转换为limit) Returns: Dict: 包含 total(总数) 和 items(VirusEventStats列表) """ # 火绒API使用 limit/offset 分页 limit = min(per_page, 200) offset = (page - 1) * limit body: Dict[str, Any] = { "type": query_type, "limit": limit, "offset": offset, } # 根据查询类型添加可选参数 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) # 时间范围过滤 if begin_time: body["begin_time"] = begin_time if end_time: body["end_time"] = end_time resp = await self._request("/api/clnts/_virus_events", body) data = resp.data or {} raw_items = data.get("list", []) total = data.get("total", 0) items = [VirusEventStats(**item) for item in raw_items] return { "total": total, "items": items, } # ====================================================================== # P1 接口:控制能力 # ====================================================================== async def isolate_terminal( self, client_ids: List[str], ) -> Dict[str, Any]: """隔离终端(断网)。 火绒API: POST /api/task/_create (type=netctrl, net_isolation=true) 安全等级: 🔴 高危操作,调用方必须确保: 1. 仅 admin 角色可调用 2. 已完成二次确认 3. 已记录操作原因 Args: client_ids: 目标终端ID列表 Returns: Dict: 火绒API响应的data部分 """ body = { "type": "netctrl", "net_isolation": True, "clients": client_ids, } resp = await self._request("/api/task/_create", body) logger.warning(f"火绒终端隔离操作: client_ids={client_ids}") return resp.data or {} async def unisolate_terminal( self, client_ids: List[str], ) -> Dict[str, Any]: """解除终端隔离(恢复网络)。 火绒API: POST /api/task/_create (type=netctrl, net_isolation=false) Args: client_ids: 目标终端ID列表 Returns: Dict: 火绒API响应的data部分 """ body = { "type": "netctrl", "net_isolation": False, "clients": client_ids, } resp = await self._request("/api/task/_create", body) logger.info(f"火绒终端解除隔离: client_ids={client_ids}") return resp.data or {} async def create_scan_task( self, client_ids: List[str], scan_type: str = "quick_scan", ) -> Dict[str, Any]: """创建终端扫描任务。 火绒API: POST /api/task/_create 扫描类型: quick_scan(快速扫描) / full_scan(全盘扫描) / custom_scan(自定义扫描) Args: client_ids: 目标终端ID列表 scan_type: 扫描类型,默认快速扫描 Returns: Dict: 火绒API响应的data部分 """ body = { "type": scan_type, "clients": client_ids, } resp = await self._request("/api/task/_create", body) logger.info(f"火绒终端扫描任务: type={scan_type}, client_ids={client_ids}") return resp.data or {} async def send_notification( self, client_ids: List[str], content: str, ) -> Dict[str, Any]: """向终端发送通知。 火绒API: POST /api/task/_create (type=message) Args: client_ids: 目标终端ID列表 content: 通知内容 Returns: Dict: 火绒API响应的data部分 """ body = { "type": "message", "clients": client_ids, "content": content, } resp = await self._request("/api/task/_create", body) logger.info(f"火绒终端通知: client_ids={client_ids}, content={content[:50]}") return resp.data or {} # ====================================================================== # 测试连接 # ====================================================================== async def test_connection(self) -> Dict[str, Any]: """测试火绒API连接是否正常。 使用 _list 接口(page=1, per_page=1)进行轻量级连接测试, 验证签名是否正确、网络是否可达。 Returns: Dict: 包含 success(bool) 和 message(str) """ try: result = await self.list_terminals(page=1, per_page=1) return { "success": True, "message": f"连接成功,共 {result.get('total', 0)} 个终端", "total_terminals": result.get("total", 0), } except HuorongAuthError as e: return { "success": False, "message": f"认证失败: {e.message}", } except HuorongConnectionError as e: return { "success": False, "message": f"连接失败: {e.message}", } except HuorongError as e: return { "success": False, "message": f"测试失败: {e.message}", }