chore: initial baseline with P0-safety .gitignore

This commit is contained in:
Simon
2026-06-14 16:49:18 +08:00
commit 63262292d7
510 changed files with 146008 additions and 0 deletions
@@ -0,0 +1,3 @@
# =============================================================================
# 企微IT智能服务台 — 火绒终端安全集成模块包
# =============================================================================
+658
View File
@@ -0,0 +1,658 @@
# =============================================================================
# 企微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}",
}
@@ -0,0 +1,87 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成配置管理
# =============================================================================
# 说明:从系统配置表(system_configs)读取火绒 AccessKey/Secret/BaseUrl
# 构建火绒API客户端实例
# =============================================================================
import logging
from typing import Optional
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.system_config import SystemConfig
from .client import HuorongClient
from .exceptions import HuorongConfigError
logger = logging.getLogger(__name__)
# 火绒配置在 system_configs 表中的 key 前缀
HUORONG_CONFIG_PREFIX = "integration_huorong_"
async def get_huorong_client(db: AsyncSession) -> HuorongClient:
"""从系统配置表构建火绒API客户端。
读取 integration_huorong_ 前缀的配置项,构建 HuorongClient 实例。
如果任何必填配置缺失,抛出 HuorongConfigError。
Args:
db: 数据库会话
Returns:
HuorongClient: 已配置的火绒API客户端实例
Raises:
HuorongConfigError: AccessKey ID/Secret/Base URL 任一缺失
"""
# 读取三个必填配置
result = await db.execute(
select(SystemConfig).where(
SystemConfig.config_key.startswith(HUORONG_CONFIG_PREFIX)
)
)
configs = list(result.scalars().all())
# 构建 key→value 映射
config_map = {cfg.config_key: cfg.config_value for cfg in configs}
access_key_id = config_map.get(f"{HUORONG_CONFIG_PREFIX}access_key_id", "")
access_key_secret = config_map.get(f"{HUORONG_CONFIG_PREFIX}access_key_secret", "")
base_url = config_map.get(f"{HUORONG_CONFIG_PREFIX}base_url", "")
# 校验必填项
if not access_key_id or not access_key_secret or not base_url:
missing = []
if not access_key_id:
missing.append("AccessKey ID")
if not access_key_secret:
missing.append("AccessKey Secret")
if not base_url:
missing.append("Base URL")
raise HuorongConfigError(
f"火绒集成配置不完整,缺失: {', '.join(missing)},请先在管理后台完成配置"
)
return HuorongClient(
access_key_id=access_key_id,
access_key_secret=access_key_secret,
base_url=base_url,
)
async def is_huorong_configured(db: AsyncSession) -> bool:
"""检查火绒集成是否已完整配置。
Args:
db: 数据库会话
Returns:
bool: 三项配置均存在且非空时返回 True
"""
try:
client = await get_huorong_client(db)
return bool(client.access_key_id and client.access_key_secret and client.base_url)
except HuorongConfigError:
return False
@@ -0,0 +1,63 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成自定义异常
# =============================================================================
# 说明:火绒API调用中可能抛出的各种异常类型
# 包含:认证错误、连接超时、API错误码等
# =============================================================================
class HuorongError(Exception):
"""火绒集成基础异常。
所有火绒相关异常的父类,便于统一捕获处理。
Attributes:
code: 错误码(火绒API返回的errcode,或自定义错误码)
message: 错误描述
"""
def __init__(self, code: int = -1, message: str = "火绒API调用失败"):
self.code = code
self.message = message
super().__init__(f"[HuorongError:{code}] {message}")
class HuorongAuthError(HuorongError):
"""火绒认证失败异常。
场景:AccessKey ID/Secret 无效、签名校验失败、权限不足
火绒API返回 errcode=401 或签名相关错误时抛出。
"""
def __init__(self, message: str = "火绒API认证失败,请检查AccessKey配置"):
super().__init__(code=401, message=message)
class HuorongConnectionError(HuorongError):
"""火绒连接失败异常。
场景:内网地址不通、超时、DNS解析失败
"""
def __init__(self, message: str = "无法连接火绒服务器,请检查网络和Base URL配置"):
super().__init__(code=502, message=message)
class HuorongConfigError(HuorongError):
"""火绒配置缺失异常。
场景:AccessKey ID/Secret/Base URL 未在系统配置中设置
"""
def __init__(self, message: str = "火绒集成未配置,请先在管理后台设置AccessKey和Base URL"):
super().__init__(code=400, message=message)
class HuorongApiError(HuorongError):
"""火绒API业务错误。
场景:火绒API返回非0 errcode(如参数错误、终端不存在等)
"""
def __init__(self, code: int, message: str):
super().__init__(code=code, message=message)
+373
View File
@@ -0,0 +1,373 @@
# =============================================================================
# 企微IT智能服务台 — 火绒集成数据模型
# =============================================================================
# 说明:火绒API请求/响应的 Pydantic 数据模型
# 包含:终端信息、漏洞信息、病毒事件、任务下发等
# =============================================================================
from typing import Any, Dict, List, Optional
from pydantic import BaseModel, Field, model_validator
# ==========================================================================
# 通用响应模型
# ==========================================================================
class HuorongApiResponse(BaseModel):
"""火绒API统一响应模型。
火绒所有API返回格式一致(官方API文档 v1):
成功时: { "errno": 0, "errmsg": "", "data": { ... } }
失败时: { "errno": 1, "errmsg": "Authentication failed" }
官方错误码定义:
- errno=0: 成功
- errno=1: 认证失败
- errno=2: 参数错误
- errno=3: 服务器内部错误
- errno=4: API未授权
注意:火绒API始终使用 errno(不是 errcode)。
使用 model_validator 在验证前将 errno 归一化为 errcode
保持内部代码统一使用 errcode 字段。
Attributes:
errcode: 错误码,0表示成功(从 errno 归一化而来)
errmsg: 错误描述(成功时为空字符串)
data: 业务数据(成功时非None)
"""
@model_validator(mode='before')
@classmethod
def normalize_error_fields(cls, data: Any) -> Any:
"""将火绒API返回的 errno 字段归一化为 errcode。
火绒API在认证失败等错误场景下返回 errno 而非 errcode
此验证器在 Pydantic 字段校验前将 errno 转换为 errcode
统一后续处理逻辑。
Args:
data: 原始输入数据(通常为dict)
Returns:
归一化后的数据
"""
if isinstance(data, dict) and 'errno' in data and 'errcode' not in data:
data['errcode'] = data.pop('errno')
return data
errcode: int = Field(..., description="错误码,0=成功")
errmsg: str = Field(default="ok", description="错误描述")
data: Optional[Any] = Field(default=None, description="业务数据")
# ==========================================================================
# 终端基本信息 — /api/clnts/_list 返回
# ==========================================================================
class TerminalBasicInfo(BaseModel):
"""终端基本信息(_list 接口返回的每条记录)。
字段名严格按照火绒API文档实际返回值定义。
注意:API返回的字段名与之前猜测不同,已根据官方文档修正。
Attributes:
id: 内部数据库ID
client_id: 终端唯一ID(40位十六进制字符串,用于所有任务下发)
client_name: 客户端名称
computer_name: 计算机名
local_ip: 本地IP
connect_ip: 连接IP(客户端连接控制中心使用的IP)
mac: MAC地址
group_id: 分组ID
os_version: 操作系统版本
version: 火绒客户端版本
definitions: 病毒库更新时间
is_online: 在线状态
last_connect_time: 最后连接时间(Unix时间戳)
last_seen_time: 最后可见时间(Unix时间戳)
first_appear_time: 首次出现时间(Unix时间戳)
"""
id: Optional[int] = Field(default=None, description="内部数据库ID")
client_id: str = Field(..., description="终端唯一ID")
client_name: str = Field(default="", description="客户端名称")
computer_name: str = Field(default="", description="计算机名")
local_ip: str = Field(default="", description="本地IP")
connect_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
group_id: Optional[Any] = Field(default=None, description="分组IDint或str")
os_version: str = Field(default="", description="操作系统版本")
version: str = Field(default="", description="火绒客户端版本")
definitions: str = Field(default="", description="病毒库更新时间")
is_online: bool = Field(default=False, description="在线状态")
last_connect_time: Optional[int] = Field(default=None, description="最后连接时间")
last_seen_time: Optional[int] = Field(default=None, description="最后可见时间")
first_appear_time: Optional[int] = Field(default=None, description="首次出现时间")
class TerminalListRequest(BaseModel):
"""终端列表查询请求。
Attributes:
group_id: 分组ID(可选,不传则查全部分组)
page: 页码(从1开始)
per_page: 每页条数
"""
group_id: Optional[str] = Field(default=None, description="分组ID")
page: int = Field(default=1, ge=1, description="页码")
per_page: int = Field(default=20, ge=1, le=100, description="每页条数")
# ==========================================================================
# 终端详细信息v2 — /api/clnts/_info2 返回
# ==========================================================================
class HardwareInfo(BaseModel):
"""终端硬件信息。
Attributes:
cpu: CPU信息
memory: 内存信息
disk: 磁盘信息
motherboard: 主板信息
network_card: 网卡信息
"""
cpu: str = Field(default="", description="CPU信息")
memory: str = Field(default="", description="内存信息")
disk: str = Field(default="", description="磁盘信息")
motherboard: str = Field(default="", description="主板信息")
network_card: str = Field(default="", description="网卡信息")
class SoftwareInfo(BaseModel):
"""已安装软件条目。
Attributes:
name: 软件名称
version: 版本号
publisher: 发布者
"""
name: str = Field(default="", description="软件名称")
version: str = Field(default="", description="版本号")
publisher: str = Field(default="", description="发布者")
class AssetInfo(BaseModel):
"""资产信息。
Attributes:
asset_tag: 资产标签
serial_number: 序列号
"""
asset_tag: str = Field(default="", description="资产标签")
serial_number: str = Field(default="", description="序列号")
class NetworkConfig(BaseModel):
"""网络配置信息。
Attributes:
ip: IP地址
gateway: 网关
dns: DNS服务器
adapter_info: 网卡适配器信息
"""
ip: str = Field(default="", description="IP地址")
gateway: str = Field(default="", description="网关")
dns: str = Field(default="", description="DNS服务器")
adapter_info: str = Field(default="", description="网卡适配器信息")
class TerminalDetailV2(BaseModel):
"""终端详细信息v2(_info2 接口返回)。
通过 optional_fields 参数指定需要返回的信息块:
- hardware: 硬件信息
- software: 已安装软件
- assets: 资产信息
- netconf: 网络配置
Attributes:
client_id: 终端唯一ID
computer_name: 计算机名
hardware: 硬件信息(可选)
software: 已安装软件列表(可选)
assets: 资产信息(可选)
netconf: 网络配置(可选)
"""
client_id: str = Field(..., description="终端唯一ID")
computer_name: str = Field(default="", description="计算机名")
hardware: Optional[HardwareInfo] = Field(default=None, description="硬件信息")
software: Optional[List[SoftwareInfo]] = Field(default=None, description="已安装软件")
assets: Optional[AssetInfo] = Field(default=None, description="资产信息")
netconf: Optional[NetworkConfig] = Field(default=None, description="网络配置")
class TerminalDetailRequest(BaseModel):
"""终端详细信息查询请求。
Attributes:
client_id: 终端唯一ID
optional_fields: 需要返回的可选信息块列表
"""
client_id: str = Field(..., description="终端唯一ID")
optional_fields: List[str] = Field(
default_factory=lambda: ["hardware", "software", "assets", "netconf"],
description="可选信息块: hardware/software/assets/netconf",
)
# ==========================================================================
# 漏洞信息 — /api/clnts/_leak 返回
# 说明:_leak 接口返回的是"存在高危漏洞未修复的终端列表",
# 每条记录是终端信息(非漏洞详情),API不返回具体漏洞CVE列表。
# 外层还有 all_client(终端总数)和 risk_client(高危终端数)统计。
# ==========================================================================
class TerminalLeakInfo(BaseModel):
"""存在高危漏洞的终端信息(_leak 接口返回的每条记录)。
注意:_leak 返回的是终端维度数据,不是漏洞维度。
字段名严格按照火绒API文档实际返回值定义。
与 _list 接口的字段名不同!
Attributes:
cid: 终端唯一ID_leak 中叫 cid_list 中叫 client_id
hostname: 计算机名(_leak 中叫 hostname_list 中叫 computer_name
client_name: 终端名称
group_name: 分组名称
group_id: 分组ID
ip_addr: 本地IP_leak 中叫 ip_addr_list 中叫 local_ip
call_ip: 连接IP_leak 中叫 call_ip_list 中叫 connect_ip
mac: MAC地址
osver: 操作系统版本(_leak 中叫 osver_list 中叫 os_version
os_type: 终端类型(如 Windows
prodver: 火绒客户端版本(_leak 中叫 prodver_list 中叫 version
virdb: 病毒库版本(Unix时间戳,_leak 中叫 virdb_list 中叫 definitions
stat: 在线状态码(1=离线, 2=在线, 3=异常,_list 中是 is_online 布尔值)
"""
cid: str = Field(..., description="终端唯一ID")
hostname: str = Field(default="", description="计算机名")
client_name: str = Field(default="", description="终端名称")
group_name: str = Field(default="", description="分组名称")
group_id: Optional[Any] = Field(default=None, description="分组ID")
ip_addr: str = Field(default="", description="本地IP")
call_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
osver: str = Field(default="", description="操作系统版本")
os_type: str = Field(default="", description="终端类型")
prodver: str = Field(default="", description="火绒客户端版本")
virdb: Optional[Any] = Field(default=None, description="病毒库版本(Unix时间戳)")
stat: int = Field(default=1, description="在线状态码: 1=离线 2=在线 3=异常")
# ==========================================================================
# 病毒事件 — /api/clnts/_virus_events 返回
# 说明:_virus_events 返回终端维度的病毒日志统计,
# 含总数(count)和4种处理结果(result)的明细。
# 请求需指定 type: 0=按client_id查, 1=按group_id查, 2=查全部
# ==========================================================================
class VirusHandleResult(BaseModel):
"""病毒事件处理结果统计。
Attributes:
success: 处理成功数
fail: 处理失败数
ignored: 暂不处理数
trusted: 已信任数
"""
success: int = Field(default=0, description="处理成功数")
fail: int = Field(default=0, description="处理失败数")
ignored: int = Field(default=0, description="暂不处理数")
trusted: int = Field(default=0, description="已信任数")
class VirusEventStats(BaseModel):
"""终端病毒事件统计(_virus_events 接口返回的每条记录)。
字段名严格按照火绒API文档实际返回值定义。
与 _list 接口的字段名基本一致。
Attributes:
group_id: 分组ID
client_id: 终端唯一ID
client_name: 终端名称
computer_name: 计算机名
local_ip: 本地IP
connect_ip: 连接IP
mac: MAC地址
count: 病毒日志总数
result: 处理结果统计(success/fail/ignored/trusted
"""
group_id: Optional[Any] = Field(default=None, description="分组ID")
client_id: str = Field(..., description="终端唯一ID")
client_name: str = Field(default="", description="终端名称")
computer_name: str = Field(default="", description="计算机名")
local_ip: str = Field(default="", description="本地IP")
connect_ip: str = Field(default="", description="连接IP")
mac: str = Field(default="", description="MAC地址")
count: int = Field(default=0, description="病毒日志总数")
result: Optional[VirusHandleResult] = Field(default=None, description="处理结果统计")
# ==========================================================================
# 终端任务 — /api/task/_create
# ==========================================================================
class TaskCreateRequest(BaseModel):
"""终端任务创建请求。
支持的任务类型:
- quick_scan: 快速扫描
- full_scan: 全盘扫描
- custom_scan: 自定义扫描
- netctrl: 终端隔离/解除
- message: 发送通知
Attributes:
task_type: 任务类型
client_ids: 目标终端ID列表
net_isolation: 是否隔离(仅 netctrl 类型有效)
message_content: 通知内容(仅 message 类型有效)
"""
task_type: str = Field(..., description="任务类型: quick_scan/full_scan/custom_scan/netctrl/message")
client_ids: List[str] = Field(..., min_length=1, description="目标终端ID列表")
net_isolation: Optional[bool] = Field(default=None, description="是否隔离(仅netctrl类型)")
message_content: Optional[str] = Field(default=None, description="通知内容(仅message类型)")
# ==========================================================================
# 终端安全画像(聚合模型,供前端直接使用)
# ==========================================================================
class TerminalSecurityProfile(BaseModel):
"""终端安全画像(聚合模型)。
将终端基本信息+安全状态聚合成一个模型,供坐席端直接展示。
Attributes:
client_id: 终端唯一ID
computer_name: 计算机名
ip: 本地IP
mac: MAC地址
os_version: 操作系统版本
is_online: 在线状态
group_name: 分组名称
hardware: 硬件概要
high_risk_leaks: 高危漏洞数
uncleaned_virus: 未处理病毒事件数
security_score: 安全评分(0-100,综合漏洞+病毒+在线状态)
"""
client_id: str = Field(..., description="终端唯一ID")
computer_name: str = Field(default="", description="计算机名")
ip: str = Field(default="", description="本地IP")
mac: str = Field(default="", description="MAC地址")
os_version: str = Field(default="", description="操作系统版本")
is_online: bool = Field(default=False, description="在线状态")
group_name: str = Field(default="", description="分组名称")
hardware: Optional[HardwareInfo] = Field(default=None, description="硬件概要")
high_risk_leaks: int = Field(default=0, description="高危漏洞数")
uncleaned_virus: int = Field(default=0, description="未处理病毒事件数")
security_score: int = Field(default=100, description="安全评分(0-100)")