Files
wecom_it_smart_desk/backend/app/integrations/huorong/client.py
T

659 lines
24 KiB
Python
Raw Normal View History

# =============================================================================
# 企微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-MD5RFC2616)。
算法步骤:
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 MD5base64编码)
- 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-MD5RFC2616: 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: 终端唯一IDtype=0时必填)
group_id: 分组IDtype=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}",
}