659 lines
24 KiB
Python
659 lines
24 KiB
Python
|
|
# =============================================================================
|
|||
|
|
# 企微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}",
|
|||
|
|
}
|