1783 lines
70 KiB
Python
1783 lines
70 KiB
Python
|
|
"""控制台路由(P2)。
|
|||
|
|
|
|||
|
|
职责:
|
|||
|
|
- 登录 / 登出(session cookie + CSRF + 失败锁定)
|
|||
|
|
- 策略配置页面(表单+YAML 双模式 + 版本历史 + 一键回滚)
|
|||
|
|
- 任务历史查询
|
|||
|
|
- 引擎状态看板
|
|||
|
|
- 权限分级(admin / operator / viewer)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import hashlib
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import secrets
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
from datetime import datetime, timedelta, timezone
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any, Dict, List, Optional
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, Response, UploadFile
|
|||
|
|
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
|||
|
|
from fastapi.templating import Jinja2Templates
|
|||
|
|
from sqlalchemy import select
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from app.db import SessionLocal
|
|||
|
|
from app.models import (
|
|||
|
|
AuditLog,
|
|||
|
|
ConsoleUser,
|
|||
|
|
LoginAttempt,
|
|||
|
|
PolicyVersion,
|
|||
|
|
ScanFinding,
|
|||
|
|
ScanTask,
|
|||
|
|
)
|
|||
|
|
from app.security.crypto import verify_password, hash_password
|
|||
|
|
from app.services.policy import PolicyStore
|
|||
|
|
|
|||
|
|
router = APIRouter(prefix="/console", tags=["console"])
|
|||
|
|
|
|||
|
|
TEMPLATES_DIR = Path(__file__).resolve().parent / "templates"
|
|||
|
|
TEMPLATES_DIR.mkdir(parents=True, exist_ok=True)
|
|||
|
|
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =============================================================================
|
|||
|
|
# Jinja2 自定义过滤器
|
|||
|
|
# =============================================================================
|
|||
|
|
def _bj_time(dt: Any, fmt: str = "%Y-%m-%d %H:%M:%S") -> str:
|
|||
|
|
"""把 Postgres 里存的 UTC datetime 转成北京时间再格式化。
|
|||
|
|
|
|||
|
|
兼容三种输入:
|
|||
|
|
1. None → 返回 ''
|
|||
|
|
2. datetime 对象(ORM 查出来的)→ 直接转
|
|||
|
|
3. ISO format 字符串(PolicyStore API 返回的)→ 先 fromisoformat 转
|
|||
|
|
"""
|
|||
|
|
if dt is None:
|
|||
|
|
return ""
|
|||
|
|
from datetime import datetime as _dt
|
|||
|
|
if isinstance(dt, str):
|
|||
|
|
# 字符串:ISO format,如 "2026-10-27T12:00:00+00:00" 或 "2026-10-27 12:00:00"
|
|||
|
|
try:
|
|||
|
|
dt = _dt.fromisoformat(dt.replace("Z", "+00:00"))
|
|||
|
|
except Exception:
|
|||
|
|
return dt # 解析不了原样返回
|
|||
|
|
# naive datetime → 当作 UTC
|
|||
|
|
if dt.tzinfo is None:
|
|||
|
|
dt = dt.replace(tzinfo=timezone.utc)
|
|||
|
|
# 直接 +8 小时(Asia/Shanghai 无夏令时)
|
|||
|
|
bj = dt.astimezone(timezone(timedelta(hours=8)))
|
|||
|
|
return bj.strftime(fmt)
|
|||
|
|
|
|||
|
|
|
|||
|
|
templates.env.filters["bj_time"] = _bj_time
|
|||
|
|
templates.env.filters["bj_date"] = lambda dt: _bj_time(dt, "%Y-%m-%d")
|
|||
|
|
templates.env.filters["bj_clock"] = lambda dt: _bj_time(dt, "%H:%M:%S")
|
|||
|
|
|
|||
|
|
# ---------- 统一中文标签体系过滤器 ----------
|
|||
|
|
from app.i18n_labels import ( # noqa: E402
|
|||
|
|
action_badge, action_label, category_label, channel_label,
|
|||
|
|
engine_label, mode_label, risk_badge, risk_desc, risk_label, risk_matrix,
|
|||
|
|
severity_badge, severity_label, verdict_badge, verdict_label, verdict_risk,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
templates.env.filters["cn_verdict"] = verdict_label
|
|||
|
|
templates.env.filters["cn_verdict_badge"] = verdict_badge
|
|||
|
|
templates.env.filters["cn_verdict_risk"] = verdict_risk
|
|||
|
|
templates.env.filters["cn_action"] = action_label
|
|||
|
|
templates.env.filters["cn_action_badge"] = action_badge
|
|||
|
|
templates.env.filters["cn_severity"] = severity_label
|
|||
|
|
templates.env.filters["cn_severity_badge"] = severity_badge
|
|||
|
|
templates.env.filters["cn_engine"] = engine_label
|
|||
|
|
templates.env.filters["cn_mode"] = mode_label
|
|||
|
|
templates.env.filters["cn_category"] = category_label
|
|||
|
|
templates.env.filters["cn_channel"] = channel_label
|
|||
|
|
templates.env.filters["cn_risk"] = risk_label
|
|||
|
|
templates.env.filters["cn_risk_badge"] = risk_badge
|
|||
|
|
templates.env.filters["cn_risk_desc"] = risk_desc
|
|||
|
|
templates.env.globals["RISK_MATRIX"] = risk_matrix()
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =============================================================================
|
|||
|
|
# YAML 注释注入:给 yaml.dump 出来的裸文本自动加中文行尾注释
|
|||
|
|
# 用法:yaml_text = _annotate_yaml(yaml.dump(policy.raw, ...))
|
|||
|
|
# =============================================================================
|
|||
|
|
def _annotate_yaml(raw: str) -> str:
|
|||
|
|
"""给 yaml.dump 的输出逐行追加中文注释。只给我们认识的 key 加。"""
|
|||
|
|
|
|||
|
|
# -------- 字段 → 注释行 --------
|
|||
|
|
# key 匹配规则:"(缩进)key:" 格式
|
|||
|
|
_FIELD_HINTS = {
|
|||
|
|
# ---- 顶层 ----
|
|||
|
|
"version": '【str】策略版本号(自己定,如 "2026.10.2")',
|
|||
|
|
"description": '【str】策略说明(给自己/运维看的描述)',
|
|||
|
|
# ---- global ----
|
|||
|
|
"global": '全局阈值配置',
|
|||
|
|
"block_score": '【int 0~100】累计风险分 ≥ 此值 → 判定 malicious 直接拒绝',
|
|||
|
|
"review_score": '【[int, int]】累计分落在左闭右开区间 → suspicious 转人工复核',
|
|||
|
|
"engine_failure_mode": '【fail_closed | fail_open】引擎不可用时的降级策略(fail_closed=拒绝 fail_open=放行)',
|
|||
|
|
"max_file_size": '【字节】单文件最大体积,超过直接拒绝(生产推荐 52428800 = 50MB)',
|
|||
|
|
"sync_max_size": '【字节】同步扫描上限,超过自动转异步',
|
|||
|
|
"allow_encrypted_archive": '【true/false】是否允许带密码的加密 ZIP/RAR/7z 压缩包',
|
|||
|
|
"allow_encrypted_document": '【true/false】是否允许加密 Office 文档(.docx/.xlsx/.pptx)',
|
|||
|
|
# ---- 零容忍 ----
|
|||
|
|
"zero_tolerance_rules": '命中其中任意一条 → 直接 reject,不走评分。⚠️ 删一条=少一层防御!',
|
|||
|
|
# ---- 扩展名 ----
|
|||
|
|
"allowed_extensions": '白名单扩展名(带 . 小写)。生产必须填全,不在列表直接拒绝',
|
|||
|
|
"sandbox_required_extensions": '一期无沙箱,预留字段(命中自动转异步)',
|
|||
|
|
# ---- rule_overrides ----
|
|||
|
|
"rule_overrides": '特定规则的处置覆盖。action: pass | manual_review | reject',
|
|||
|
|
# ---- yara ----
|
|||
|
|
"yara_rule_weights": 'YARA 规则按严重度分档赋权重(critical=85 high=65 medium=45 low=25)',
|
|||
|
|
# ---- keyword ----
|
|||
|
|
"keyword_rules": '词表引擎规则。match: contains | regex | any_of',
|
|||
|
|
# ---- notification ----
|
|||
|
|
"notification": '拒绝通知设置',
|
|||
|
|
"include_reason": '【true/false】拒绝时是否返回详细原因给调用方',
|
|||
|
|
"max_reason_length": '【int】拒绝原因最大长度(字符)',
|
|||
|
|
# ---- audit ----
|
|||
|
|
"audit": '审计日志设置',
|
|||
|
|
"log_findings": '【true/false】是否记录扫描命中的每条 finding',
|
|||
|
|
"log_extracted_paths": '【true/false】是否记录从压缩包提取的子文件路径',
|
|||
|
|
# ---- 操作指引标题 ----
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 零容忍规则名 → 中文(行内注释)
|
|||
|
|
_ZT_HINTS = {
|
|||
|
|
"PdfJavaScript": "PDF 打开即执行脚本(跨平台)",
|
|||
|
|
"PdfLaunchAction": "PDF 打开即启动外部程序",
|
|||
|
|
"PdfOpenAction": "PDF 自动打开对象",
|
|||
|
|
"PathTraversal": "解包路径穿越(../ 符号)",
|
|||
|
|
"IsoWithExecutable": "ISO 挂载后内部含可执行文件",
|
|||
|
|
"MacroObfuscated": "宏代码混淆/加密(意图不可知)",
|
|||
|
|
"AttachedTemplate": "Office 外部模板注入(.dotm/.xltm)",
|
|||
|
|
"VBAAutoExec": "VBA 宏 AutoOpen 自动执行入口",
|
|||
|
|
"VBAAutoExecShell": "VBA 宏自动执行 + Shell/CreateObject 调用",
|
|||
|
|
"MRaptorAutoExec": "oletools MRaptor 判定为自动执行宏",
|
|||
|
|
"MRaptorAutoExecSuspicious": "MRaptor 判定为可疑自动执行宏",
|
|||
|
|
"DDEAutoExecution": "DDE/DDEAUTO 自动执行字段",
|
|||
|
|
"EmbeddedExecutable": "文档内嵌可执行对象(oleobj 检测)",
|
|||
|
|
"LinuxExecutable": "ELF 原生二进制",
|
|||
|
|
"DesktopFileAutoExec": ".desktop 文件 Exec= 字段含危险命令",
|
|||
|
|
"ShellScriptDangerous": "Shell/Python/Perl 脚本含危险函数",
|
|||
|
|
"LinuxPortableExecutable": "AppImage/Flatpak/Snap 便携包",
|
|||
|
|
"ArchiveContainsLinuxExec": "压缩包内混有 .elf/.sh/.deb/.run/.bin",
|
|||
|
|
"LnkWithAutoExec": "LNK 快捷方式含 PowerShell/cmd 自动执行",
|
|||
|
|
"ChmWithScript": "CHM 帮助文件内嵌 JavaScript 或引用 .exe/.vbs",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# YARA 权重分档 → 中文
|
|||
|
|
_WEIGHT_TIER_HINTS = {
|
|||
|
|
"critical": "weight=85(严重)",
|
|||
|
|
"high": "weight=65(高危)",
|
|||
|
|
"medium": "weight=45(中等)",
|
|||
|
|
"low": "weight=25(低危)",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lines = raw.split("\n")
|
|||
|
|
out = []
|
|||
|
|
for line in lines:
|
|||
|
|
# ---- 1) 零容忍规则列表项: - RuleName(可能无缩进)----
|
|||
|
|
m_zt = __import__("re").match(r'^(\s*-\s+)([A-Za-z_][A-Za-z0-9_]*)\s*$', line)
|
|||
|
|
if m_zt and m_zt.group(2) in _ZT_HINTS:
|
|||
|
|
out.append(f"{line} # {_ZT_HINTS[m_zt.group(2)]}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# ---- 2) YARA 权重分档头: critical: ----
|
|||
|
|
m_tier = __import__("re").match(r'^(\s*)(critical|high|medium|low):\s*$', line)
|
|||
|
|
if m_tier and m_tier.group(2) in _WEIGHT_TIER_HINTS:
|
|||
|
|
out.append(f"{line} # {_WEIGHT_TIER_HINTS[m_tier.group(2)]}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# ---- 3) 普通 key: value ----
|
|||
|
|
m_key = __import__("re").match(r'^(\s*)([a-z_][a-z0-9_]*)(:.*)$', line)
|
|||
|
|
if m_key:
|
|||
|
|
key = m_key.group(2)
|
|||
|
|
hint = _FIELD_HINTS.get(key)
|
|||
|
|
if hint and "#" not in line:
|
|||
|
|
out.append(f"{line} # {hint}")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
out.append(line)
|
|||
|
|
|
|||
|
|
# 顶部加操作指引 header
|
|||
|
|
header = (
|
|||
|
|
"# ============================================================\n"
|
|||
|
|
"# MeshSafe 全局检测策略 — 在线编辑(中文注释自动生成)\n"
|
|||
|
|
"# ------------------------------------------------------------\n"
|
|||
|
|
"# ✅ 保存后立即热重载生效,无需重启服务\n"
|
|||
|
|
"# ⚠️ 零容忍规则动一条=少一层防御,改之前请确认\n"
|
|||
|
|
"# ❌ engine_failure_mode 不要改成 fail_open(放行所有文件!)\n"
|
|||
|
|
"# 💾 每次保存自动生成版本快照,可在「版本历史」回滚\n"
|
|||
|
|
"# ============================================================\n"
|
|||
|
|
)
|
|||
|
|
return header + "\n".join(out) + "\n"
|
|||
|
|
|
|||
|
|
|
|||
|
|
# session 存储(优先 Redis 跨 worker 共享;Redis 不可用时降级为内存 dict)
|
|||
|
|
# key 格式: meshsafe:console_session:{session_id} TTL=30min
|
|||
|
|
_session_store: Dict[str, Dict[str, Any]] = {}
|
|||
|
|
_session_lock = threading.Lock()
|
|||
|
|
_SESSION_TTL_SECONDS = 30 * 60 # 30 分钟
|
|||
|
|
_MAX_FAILED_ATTEMPTS = 5
|
|||
|
|
_LOCKOUT_DURATION_SECONDS = 15 * 60 # 15 分钟
|
|||
|
|
_SESSION_KEY_PREFIX = "meshsafe:console_session:"
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _session_redis_set(session_id: str, data: dict) -> bool:
|
|||
|
|
"""写 session 到 Redis。成功返回 True,失败返回 False。"""
|
|||
|
|
try:
|
|||
|
|
from app.security.deps import get_redis
|
|||
|
|
r = get_redis()
|
|||
|
|
key = f"{_SESSION_KEY_PREFIX}{session_id}"
|
|||
|
|
# Redis hash 不能直接存 Python dict,需要把值转成字符串
|
|||
|
|
# expires_at 存 float 时间戳字符串方便比较
|
|||
|
|
mapping = {k: str(v) for k, v in data.items()}
|
|||
|
|
r.hset(key, mapping=mapping)
|
|||
|
|
r.expire(key, _SESSION_TTL_SECONDS)
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _session_redis_get(session_id: str) -> Optional[Dict[str, Any]]:
|
|||
|
|
"""从 Redis 读 session。不存在或 Redis 不可用返回 None。"""
|
|||
|
|
try:
|
|||
|
|
from app.security.deps import get_redis
|
|||
|
|
r = get_redis()
|
|||
|
|
key = f"{_SESSION_KEY_PREFIX}{session_id}"
|
|||
|
|
data = r.hgetall(key)
|
|||
|
|
if not data:
|
|||
|
|
return None
|
|||
|
|
# 类型还原
|
|||
|
|
result: Dict[str, Any] = {}
|
|||
|
|
for k, v in data.items():
|
|||
|
|
if k in ("user_id",):
|
|||
|
|
result[k] = int(v)
|
|||
|
|
elif k == "expires_at":
|
|||
|
|
result[k] = float(v)
|
|||
|
|
else:
|
|||
|
|
result[k] = v
|
|||
|
|
return result
|
|||
|
|
except Exception:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _session_redis_delete(session_id: str) -> bool:
|
|||
|
|
"""从 Redis 删 session。"""
|
|||
|
|
try:
|
|||
|
|
from app.security.deps import get_redis
|
|||
|
|
r = get_redis()
|
|||
|
|
r.delete(f"{_SESSION_KEY_PREFIX}{session_id}")
|
|||
|
|
return True
|
|||
|
|
except Exception:
|
|||
|
|
return False
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 认证依赖
|
|||
|
|
# =====================================================================
|
|||
|
|
|
|||
|
|
def get_current_user(request: Request) -> ConsoleUser:
|
|||
|
|
"""从 session cookie 解析当前登录用户。未登录返回 None。"""
|
|||
|
|
session_id = request.cookies.get("meshsafe_session")
|
|||
|
|
if not session_id:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
# 优先 Redis,降级内存
|
|||
|
|
session = _session_redis_get(session_id)
|
|||
|
|
if session is None:
|
|||
|
|
with _session_lock:
|
|||
|
|
session = _session_store.get(session_id)
|
|||
|
|
|
|||
|
|
if not session:
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
if time.time() > session.get("expires_at", 0):
|
|||
|
|
_session_redis_delete(session_id)
|
|||
|
|
with _session_lock:
|
|||
|
|
_session_store.pop(session_id, None)
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
db: Session = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
user = db.get(ConsoleUser, session.get("user_id"))
|
|||
|
|
if not user or not user.is_active:
|
|||
|
|
return None
|
|||
|
|
if user.locked_until and user.locked_until > datetime.now(timezone.utc):
|
|||
|
|
return None
|
|||
|
|
return user
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def require_admin(current: ConsoleUser = Depends(get_current_user)) -> ConsoleUser:
|
|||
|
|
if current is None:
|
|||
|
|
raise HTTPException(status_code=401, detail="未登录")
|
|||
|
|
if current.role != "admin":
|
|||
|
|
raise HTTPException(status_code=403, detail="需要 admin 权限")
|
|||
|
|
return current
|
|||
|
|
|
|||
|
|
|
|||
|
|
def require_login(current: ConsoleUser = Depends(get_current_user)) -> ConsoleUser:
|
|||
|
|
if current is None:
|
|||
|
|
raise HTTPException(status_code=401, detail="未登录")
|
|||
|
|
return current
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _log_audit(actor: str, action: str, target: str = "", detail: Optional[dict] = None) -> None:
|
|||
|
|
"""写一条审计日志(带哈希链)。"""
|
|||
|
|
db: Session = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
last = db.query(AuditLog).order_by(AuditLog.id.desc()).first()
|
|||
|
|
prev_hash = last.row_hash if last else ""
|
|||
|
|
row_str = f"{actor}|{action}|{target}|{time.time()}"
|
|||
|
|
row_hash = hashlib.sha256((prev_hash + row_str).encode()).hexdigest()
|
|||
|
|
log = AuditLog(
|
|||
|
|
actor=actor, actor_role="console", action=action, target=target,
|
|||
|
|
outcome="success", detail=detail or {},
|
|||
|
|
prev_hash=prev_hash, row_hash=row_hash,
|
|||
|
|
)
|
|||
|
|
db.add(log)
|
|||
|
|
db.commit()
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
pass
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 登录 / 登出
|
|||
|
|
# =====================================================================
|
|||
|
|
|
|||
|
|
@router.get("/login", response_class=HTMLResponse, include_in_schema=False)
|
|||
|
|
async def login_page(request: Request, error: Optional[str] = None):
|
|||
|
|
return templates.TemplateResponse("login.html", {"request": request, "error": error})
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/login", include_in_schema=False)
|
|||
|
|
async def login_submit(
|
|||
|
|
request: Request,
|
|||
|
|
response: Response,
|
|||
|
|
username: str = Form(...),
|
|||
|
|
password: str = Form(...),
|
|||
|
|
):
|
|||
|
|
db: Session = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
user = db.execute(
|
|||
|
|
select(ConsoleUser).where(ConsoleUser.username == username)
|
|||
|
|
).scalar_one_or_none()
|
|||
|
|
|
|||
|
|
# 检查锁定
|
|||
|
|
if user and user.locked_until and user.locked_until > datetime.now(timezone.utc):
|
|||
|
|
locked_min = int((user.locked_until - datetime.now(timezone.utc)).total_seconds() / 60)
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"login.html",
|
|||
|
|
{"request": request, "error": f"账号已锁定,请 {locked_min} 分钟后再试"},
|
|||
|
|
status_code=401,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
success = False
|
|||
|
|
if user and verify_password(password, user.password_hash):
|
|||
|
|
success = True
|
|||
|
|
|
|||
|
|
# 记录尝试
|
|||
|
|
attempt = LoginAttempt(
|
|||
|
|
username=username,
|
|||
|
|
client_ip=request.client.host if request.client else "",
|
|||
|
|
success=success,
|
|||
|
|
)
|
|||
|
|
db.add(attempt)
|
|||
|
|
|
|||
|
|
if not success or not user:
|
|||
|
|
# 累加失败次数 + 锁定
|
|||
|
|
if user:
|
|||
|
|
user.failed_attempts += 1
|
|||
|
|
if user.failed_attempts >= _MAX_FAILED_ATTEMPTS:
|
|||
|
|
user.locked_until = datetime.now(timezone.utc) + timedelta(seconds=_LOCKOUT_DURATION_SECONDS)
|
|||
|
|
user.failed_attempts = 0
|
|||
|
|
db.commit()
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"login.html",
|
|||
|
|
{"request": request, "error": "用户名或密码错误"},
|
|||
|
|
status_code=401,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 登录成功:重置失败计数 + 发放 session
|
|||
|
|
user.failed_attempts = 0
|
|||
|
|
user.last_login_at = datetime.now(timezone.utc)
|
|||
|
|
db.commit()
|
|||
|
|
|
|||
|
|
session_id = secrets.token_urlsafe(32)
|
|||
|
|
expires_at = time.time() + _SESSION_TTL_SECONDS
|
|||
|
|
session_data = {
|
|||
|
|
"user_id": user.id,
|
|||
|
|
"username": user.username,
|
|||
|
|
"role": user.role,
|
|||
|
|
"expires_at": expires_at,
|
|||
|
|
}
|
|||
|
|
_session_redis_set(session_id, session_data)
|
|||
|
|
with _session_lock:
|
|||
|
|
_session_store[session_id] = session_data
|
|||
|
|
|
|||
|
|
response = RedirectResponse(url="/console/", status_code=303)
|
|||
|
|
response.set_cookie(
|
|||
|
|
"meshsafe_session", session_id,
|
|||
|
|
httponly=True, samesite="lax", max_age=_SESSION_TTL_SECONDS,
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
_log_audit(user.username, "login_success", "")
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/logout", include_in_schema=False)
|
|||
|
|
async def logout(request: Request, current=Depends(require_login)):
|
|||
|
|
session_id = request.cookies.get("meshsafe_session", "")
|
|||
|
|
_session_redis_delete(session_id)
|
|||
|
|
with _session_lock:
|
|||
|
|
_session_store.pop(session_id, None)
|
|||
|
|
response = RedirectResponse(url="/console/login", status_code=303)
|
|||
|
|
response.delete_cookie("meshsafe_session")
|
|||
|
|
_log_audit(current.username, "logout", "")
|
|||
|
|
return response
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 控制台主页(引擎状态看板)
|
|||
|
|
# =====================================================================
|
|||
|
|
|
|||
|
|
@router.get("/", response_class=HTMLResponse, include_in_schema=False)
|
|||
|
|
async def dashboard(request: Request, current=Depends(require_login)):
|
|||
|
|
from app.main import policy_store, scanner
|
|||
|
|
|
|||
|
|
# ---------- 引擎状态中文化 ----------
|
|||
|
|
ENGINE_CN = {
|
|||
|
|
"structure": {"name": "文件结构分析", "layer": "L0 文件结构", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
|
|||
|
|
"clamav": {"name": "ClamAV 病毒库", "layer": "L1 病毒检测", "status_ok": "正常", "status_error": "不可用",
|
|||
|
|
"info_map": {"rules loaded": "规则库已加载"},
|
|||
|
|
"err_map": {"getaddrinfo failed": "本地未启动 ClamAV 服务(生产部署后自动恢复)",
|
|||
|
|
"ConnectionRefused": "ClamAV 连接被拒绝",
|
|||
|
|
"ECONNREFUSED": "ClamAV 连接被拒绝",
|
|||
|
|
"BrokenPipeError": "ClamAV 管道破裂"}},
|
|||
|
|
"document": {"name": "文档深度检测", "layer": "L2 文档解析", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
|
|||
|
|
"keywords": {"name": "关键词匹配", "layer": "L2 特征匹配", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
|
|||
|
|
"yara": {"name": "YARA 规则匹配", "layer": "L2 规则匹配", "status_ok": "正常", "status_error": "异常",
|
|||
|
|
"info_map": {"rules loaded": "规则库已加载"}},
|
|||
|
|
"binary": {"name": "PE/二进制分析", "layer": "L2 二进制解析", "status_ok": "正常", "status_error": "异常", "info_map": {}, "err_map": {}},
|
|||
|
|
}
|
|||
|
|
engine_health = []
|
|||
|
|
for h in [e.health() for e in scanner.engines()]:
|
|||
|
|
meta = ENGINE_CN.get(h["engine"], {"name": h["engine"]})
|
|||
|
|
h["display_name"] = meta.get("name", h["engine"])
|
|||
|
|
h["layer_label"] = meta.get("layer", "")
|
|||
|
|
h["engine_version_label"] = (h.get("version") or "") if h.get("version") not in ("builtin", "unknown") else ""
|
|||
|
|
# 状态 label
|
|||
|
|
if h.get("status") == "ok":
|
|||
|
|
h["status_label"] = meta.get("status_ok", "正常")
|
|||
|
|
elif h.get("status") == "error":
|
|||
|
|
h["status_label"] = meta.get("status_error", "异常")
|
|||
|
|
else:
|
|||
|
|
h["status_label"] = h.get("status", "")
|
|||
|
|
# info → 中文映射
|
|||
|
|
raw_info = h.get("info") or ""
|
|||
|
|
h["display_info"] = meta.get("info_map", {}).get(raw_info, raw_info) if raw_info else None
|
|||
|
|
# error → 中文映射
|
|||
|
|
raw_err = str(h.get("error") or "")
|
|||
|
|
friendly_err = None
|
|||
|
|
if raw_err:
|
|||
|
|
for key, msg in meta.get("err_map", {}).items():
|
|||
|
|
if key in raw_err:
|
|||
|
|
friendly_err = msg
|
|||
|
|
break
|
|||
|
|
if not friendly_err:
|
|||
|
|
friendly_err = "检测引擎异常(%s)" % h["engine"]
|
|||
|
|
h["friendly_error"] = friendly_err
|
|||
|
|
engine_health.append(h)
|
|||
|
|
|
|||
|
|
# ---------- 系统概览统计 ----------
|
|||
|
|
policy = policy_store.current()
|
|||
|
|
|
|||
|
|
# YARA 规则数(扫描 rules/*.yar 统计 rule 声明)
|
|||
|
|
yara_rule_count = 0
|
|||
|
|
try:
|
|||
|
|
from app.engines.binary import _discover_rules_dir
|
|||
|
|
rules_dir = _discover_rules_dir()
|
|||
|
|
if os.path.isdir(rules_dir):
|
|||
|
|
for fn in sorted(os.listdir(rules_dir)):
|
|||
|
|
if not fn.endswith((".yar", ".yara")):
|
|||
|
|
continue
|
|||
|
|
try:
|
|||
|
|
with open(os.path.join(rules_dir, fn), "r", encoding="utf-8", errors="ignore") as fh:
|
|||
|
|
for line in fh:
|
|||
|
|
ls = line.strip()
|
|||
|
|
if ls.startswith("rule ") and "{" in ls or (ls.startswith("rule ") and ls.endswith("{")):
|
|||
|
|
yara_rule_count += 1
|
|||
|
|
except OSError:
|
|||
|
|
continue
|
|||
|
|
except Exception: # noqa: BLE001
|
|||
|
|
yara_rule_count = 0
|
|||
|
|
|
|||
|
|
# 规则总数 = 零容忍 + rule_overrides + 关键词规则 + YARA 规则
|
|||
|
|
total_rules = (
|
|||
|
|
len(policy.zero_tolerance_rules)
|
|||
|
|
+ len(policy.rule_overrides)
|
|||
|
|
+ len(policy.keyword_rules)
|
|||
|
|
+ yara_rule_count
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
db: Session = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
from sqlalchemy import func
|
|||
|
|
total_scans = db.query(func.count(ScanTask.id)).scalar() or 0
|
|||
|
|
total_blocked = db.query(func.count(ScanTask.id)).filter(
|
|||
|
|
ScanTask.action == "reject"
|
|||
|
|
).scalar() or 0
|
|||
|
|
total_flagged = db.query(func.count(ScanTask.id)).filter(
|
|||
|
|
ScanTask.action.in_(("reject", "manual_review", "pass_with_flag"))
|
|||
|
|
).scalar() or 0
|
|||
|
|
total_findings = db.query(func.count(ScanFinding.id)).scalar() or 0
|
|||
|
|
|
|||
|
|
# 结论分布
|
|||
|
|
verdict_rows = db.query(ScanTask.verdict, func.count(ScanTask.id)).group_by(
|
|||
|
|
ScanTask.verdict
|
|||
|
|
).all()
|
|||
|
|
verdict_dist = {str(v or "unknown"): int(c) for v, c in verdict_rows}
|
|||
|
|
|
|||
|
|
# 最近任务(8 条)
|
|||
|
|
recent_tasks = db.query(ScanTask).order_by(
|
|||
|
|
ScanTask.created_at.desc()
|
|||
|
|
).limit(8).all()
|
|||
|
|
|
|||
|
|
# 各客户端/渠道的任务数(摆渡关联性)
|
|||
|
|
client_rows = db.query(
|
|||
|
|
ScanTask.client_id, ScanTask.channel, func.count(ScanTask.id)
|
|||
|
|
).group_by(ScanTask.client_id, ScanTask.channel).all()
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
# 结论分布 → 中文 + 顺序
|
|||
|
|
VERDICT_CN = {
|
|||
|
|
"clean": ("放行", "ok"),
|
|||
|
|
"suspicious": ("可疑", "warn"),
|
|||
|
|
"malicious": ("恶意", "bad"),
|
|||
|
|
"reject": ("策略拒绝", "err"),
|
|||
|
|
"error": ("异常", "err"),
|
|||
|
|
"pending": ("处理中", "muted"),
|
|||
|
|
}
|
|||
|
|
verdict_items = []
|
|||
|
|
for key in ("clean", "suspicious", "malicious", "reject", "error", "pending"):
|
|||
|
|
cnt = verdict_dist.get(key, 0)
|
|||
|
|
if cnt <= 0:
|
|||
|
|
continue
|
|||
|
|
label, cls = VERDICT_CN.get(key, (key, "muted"))
|
|||
|
|
pct = (cnt / total_scans * 100) if total_scans else 0
|
|||
|
|
verdict_items.append({"key": key, "label": label, "count": cnt, "pct": round(pct, 1), "cls": cls})
|
|||
|
|
|
|||
|
|
# 引擎在线统计
|
|||
|
|
engines_ok = sum(1 for h in engine_health if h.get("ok"))
|
|||
|
|
engines_total = len(engine_health)
|
|||
|
|
|
|||
|
|
# 渠道 TOP(关联性)
|
|||
|
|
channel_rank = sorted(
|
|||
|
|
[{"client_id": c or "-", "channel": ch or "-", "count": int(n)} for c, ch, n in client_rows],
|
|||
|
|
key=lambda x: x["count"], reverse=True,
|
|||
|
|
)[:5]
|
|||
|
|
|
|||
|
|
stats = {
|
|||
|
|
"total_scans": total_scans,
|
|||
|
|
"total_blocked": total_blocked,
|
|||
|
|
"total_flagged": total_flagged,
|
|||
|
|
"total_findings": total_findings,
|
|||
|
|
"total_rules": total_rules,
|
|||
|
|
"zero_tolerance": len(policy.zero_tolerance_rules),
|
|||
|
|
"yara_rules": yara_rule_count,
|
|||
|
|
"engines_ok": engines_ok,
|
|||
|
|
"engines_total": engines_total,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 最近任务的中文标签
|
|||
|
|
for t in recent_tasks:
|
|||
|
|
vlabel, vcls = VERDICT_CN.get(str(t.verdict or ""), (str(t.verdict or "-"), "muted"))
|
|||
|
|
t.verdict_label = vlabel
|
|||
|
|
t.verdict_cls = vcls
|
|||
|
|
t.action_label = {
|
|||
|
|
"pass": "放行", "pass_with_flag": "带标记放行",
|
|||
|
|
"manual_review": "人工复核", "reject": "拦截", "error": "异常",
|
|||
|
|
}.get(str(t.action or ""), str(t.action or "-"))
|
|||
|
|
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"dashboard.html",
|
|||
|
|
{
|
|||
|
|
"request": request,
|
|||
|
|
"user": current,
|
|||
|
|
"engine_health": engine_health,
|
|||
|
|
"recent_tasks": recent_tasks,
|
|||
|
|
"policy_store": policy_store,
|
|||
|
|
"stats": stats,
|
|||
|
|
"verdict_items": verdict_items,
|
|||
|
|
"channel_rank": channel_rank,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 策略配置页面(P2c)
|
|||
|
|
# =====================================================================
|
|||
|
|
|
|||
|
|
@router.get("/policy", response_class=HTMLResponse, include_in_schema=False)
|
|||
|
|
async def policy_page(request: Request, current=Depends(require_login)):
|
|||
|
|
"""策略配置页面——表单模式 + YAML 模式双切换 + YARA 规则库 Tab。"""
|
|||
|
|
from app.main import policy_store
|
|||
|
|
import yaml
|
|||
|
|
|
|||
|
|
policy = policy_store.current()
|
|||
|
|
versions = policy_store.list_versions()
|
|||
|
|
|
|||
|
|
yaml_raw = yaml.dump(policy.raw, allow_unicode=True, sort_keys=False, default_flow_style=False)
|
|||
|
|
yaml_text = _annotate_yaml(yaml_raw)
|
|||
|
|
|
|||
|
|
# ---- 中文映射表 ----
|
|||
|
|
ZERO_TOLERANT_CN = {
|
|||
|
|
# 跨平台 7
|
|||
|
|
"PdfJavaScript": ("PDF 嵌入 JavaScript", "PDF 文件中包含可执行 JavaScript"),
|
|||
|
|
"PdfLaunchAction": ("PDF 启动外部程序", "PDF 试图通过 /Launch 调用系统程序"),
|
|||
|
|
"PdfOpenAction": ("PDF 自动打开对象", "PDF 含有 /OpenAction 自动执行对象"),
|
|||
|
|
"PathTraversal": ("路径穿越攻击", "压缩包内文件含 ../ 等路径穿越符号"),
|
|||
|
|
"IsoWithExecutable": ("ISO 含可执行文件", "ISO 9660 镜像中带有 .exe/.dll/.elf"),
|
|||
|
|
"MacroObfuscated": ("宏代码混淆", "Office VBA 宏使用字符串拼接或 Chrb 等混淆手法"),
|
|||
|
|
"AttachedTemplate": ("外部模板引用", "OOXML rels 指向外部 .dotm/.xltm 模板"),
|
|||
|
|
# 宏 6
|
|||
|
|
"VBAAutoExec": ("VBA 自动执行", "检测到 AutoOpen/AutoExec/Document_Open 自动执行宏"),
|
|||
|
|
"VBAAutoExecShell": ("VBA Shell 命令", "VBA 宏通过 Shell() 调用 cmd/powershell"),
|
|||
|
|
"MRaptorAutoExec": ("宏自动执行(MRaptor)", "oletools MRaptor 判定为高风险自动执行宏"),
|
|||
|
|
"MRaptorAutoExecSuspicious": ("宏可疑行为(MRaptor)", "MRaptor 发现宏含网络下载/写文件等可疑行为"),
|
|||
|
|
"DDEAutoExecution": ("DDE 自动执行", "Office 文档通过 DDE 协议执行外部命令"),
|
|||
|
|
"EmbeddedExecutable": ("内嵌可执行文件", "文档内部嵌入了 PE/ELF 二进制文件(oleobj 检测)"),
|
|||
|
|
# Linux 5
|
|||
|
|
"LinuxExecutable": ("Linux ELF 可执行文件", "文件头匹配 ELF Magic 7F 45 4C 46"),
|
|||
|
|
"DesktopFileAutoExec": (".desktop 自动执行", "GNU/Linux .desktop 文件含 Exec 自动运行命令"),
|
|||
|
|
"ShellScriptDangerous": ("Shell 脚本危险命令", "Shell 脚本含反弹 Shell/curl|bash 等高危命令"),
|
|||
|
|
"LinuxPortableExecutable": ("Linux 下 PE 文件", "Linux 平台出现 PE .exe/.dll(攻击载体)"),
|
|||
|
|
"ArchiveContainsLinuxExec": ("压缩包含 Linux ELF", "压缩包内带有 .elf 或无扩展名的 ELF"),
|
|||
|
|
# Windows 纵深 2
|
|||
|
|
"LnkWithAutoExec": ("LNK 快捷方式自动执行", "Windows 快捷方式含 PowerShell/cmd 自动执行"),
|
|||
|
|
"ChmWithScript": ("CHM 帮助含脚本", "CHM 帮助文件内嵌 JavaScript 或引用 .exe/.vbs"),
|
|||
|
|
}
|
|||
|
|
YARA_CATEGORY_CN = {
|
|||
|
|
"C1": "PE 注入与下载",
|
|||
|
|
"C3": "Webshell 后门",
|
|||
|
|
"C4": "PE Shell/壳特征",
|
|||
|
|
"C5": "PE LOLBin",
|
|||
|
|
"C6": "勒索软件",
|
|||
|
|
"C7": "Linux 后门与持久化",
|
|||
|
|
"C8": "勒索与挖矿",
|
|||
|
|
"C9": "通用特征",
|
|||
|
|
}
|
|||
|
|
RISK_LEVEL_CN = {
|
|||
|
|
"critical": ("严重", "chip-zt"),
|
|||
|
|
"high": ("高危", "chip-warn"),
|
|||
|
|
"medium": ("中等", "chip-info"),
|
|||
|
|
"low": ("低危", "chip-ok"),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 零容忍规则 → [(rule_name, cn_name, cn_desc), ...]
|
|||
|
|
zt_rules_cn = []
|
|||
|
|
for r in policy.zero_tolerance_rules:
|
|||
|
|
cn = ZERO_TOLERANT_CN.get(r, (r, r))
|
|||
|
|
zt_rules_cn.append({"rule": r, "cn_name": cn[0], "cn_desc": cn[1]})
|
|||
|
|
|
|||
|
|
# ---- YARA 规则库信息 ----
|
|||
|
|
yara_rules_info = []
|
|||
|
|
yara_loaded_count = 0
|
|||
|
|
yara_files_count = 0
|
|||
|
|
yara_rules_dir_display = ""
|
|||
|
|
try:
|
|||
|
|
import os
|
|||
|
|
# 用 binary.py 的自动探测逻辑(优先环境变量 → /app/rules → rules → ./rules → ../rules)
|
|||
|
|
from app.engines.binary import _discover_rules_dir
|
|||
|
|
import yara as yr
|
|||
|
|
|
|||
|
|
rules_dir = _discover_rules_dir()
|
|||
|
|
yara_rules_dir_display = rules_dir
|
|||
|
|
rule_files = sorted([f for f in os.listdir(rules_dir) if f.endswith(".yar")]) if os.path.isdir(rules_dir) else []
|
|||
|
|
yara_files_count = len(rule_files)
|
|||
|
|
compiled = yr.compile(filepaths={f: os.path.join(rules_dir, f) for f in rule_files}) if rule_files else None
|
|||
|
|
if compiled:
|
|||
|
|
for r in compiled:
|
|||
|
|
# yara.Rule 对象:identifier=规则名,meta=dict-like
|
|||
|
|
rule_name = getattr(r, "identifier", getattr(r, "name", "unknown"))
|
|||
|
|
meta = dict(getattr(r, "meta", {}) or {})
|
|||
|
|
tier = policy.yara_weights.get(rule_name, None)
|
|||
|
|
cat = str(meta.get("category", "-"))
|
|||
|
|
rl_raw = str(meta.get("risk_level", "-"))
|
|||
|
|
rl_cn, rl_chip = RISK_LEVEL_CN.get(rl_raw, (rl_raw, "chip-info"))
|
|||
|
|
yara_rules_info.append({
|
|||
|
|
"name": rule_name,
|
|||
|
|
"category": cat,
|
|||
|
|
"category_cn": YARA_CATEGORY_CN.get(cat, cat),
|
|||
|
|
"risk_level": rl_raw,
|
|||
|
|
"risk_level_cn": rl_cn,
|
|||
|
|
"risk_level_chip": rl_chip,
|
|||
|
|
"false_positive_rate": str(meta.get("false_positive_rate", "-")),
|
|||
|
|
"reference": str(meta.get("reference", "-")),
|
|||
|
|
"weight": f"{tier:.0f}" if tier is not None else "50",
|
|||
|
|
"trigger_count": 0,
|
|||
|
|
})
|
|||
|
|
yara_loaded_count = len(yara_rules_info)
|
|||
|
|
except Exception:
|
|||
|
|
pass # yara-python 未安装或规则目录不存在,静默降级
|
|||
|
|
|
|||
|
|
# ---- DB 中 YARA 规则触发统计(最近 24h) ----
|
|||
|
|
yara_trigger_stats: Dict[str, int] = {}
|
|||
|
|
try:
|
|||
|
|
from app.models import ScanFinding
|
|||
|
|
from sqlalchemy import func
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
rows = (
|
|||
|
|
db.query(ScanFinding.rule, func.count(ScanFinding.id))
|
|||
|
|
.filter(ScanFinding.engine == "yara")
|
|||
|
|
.group_by(ScanFinding.rule)
|
|||
|
|
.all()
|
|||
|
|
)
|
|||
|
|
yara_trigger_stats = {row[0]: row[1] for row in rows}
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 把触发次数合并到规则信息
|
|||
|
|
for ri in yara_rules_info:
|
|||
|
|
ri["trigger_count"] = yara_trigger_stats.get(ri["name"], 0)
|
|||
|
|
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"policy.html",
|
|||
|
|
{
|
|||
|
|
"request": request,
|
|||
|
|
"user": current,
|
|||
|
|
"policy": policy,
|
|||
|
|
"yaml_text": yaml_text,
|
|||
|
|
"versions": versions,
|
|||
|
|
"zt_rules_cn": zt_rules_cn,
|
|||
|
|
"yara_rules": yara_rules_info,
|
|||
|
|
"yara_loaded_count": yara_loaded_count,
|
|||
|
|
"yara_files_count": yara_files_count,
|
|||
|
|
"yara_rules_dir": yara_rules_dir_display,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/policy/save", include_in_schema=False)
|
|||
|
|
async def policy_save(
|
|||
|
|
request: Request,
|
|||
|
|
mode: str = Form(...), # "form" | "yaml"
|
|||
|
|
yaml_content: str = Form(""),
|
|||
|
|
current=Depends(require_admin),
|
|||
|
|
):
|
|||
|
|
"""保存策略。admin 权限。YAML 模式直接保存;表单模式暂在前端组装后提交 YAML。"""
|
|||
|
|
from app.main import policy_store
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
import yaml as _yaml
|
|||
|
|
parsed = _yaml.safe_load(yaml_content)
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
raise HTTPException(status_code=400, detail=f"YAML 解析失败: {exc}")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
policy_store.save_from_dict(parsed, actor=current.username)
|
|||
|
|
except ValueError as exc:
|
|||
|
|
raise HTTPException(status_code=400, detail=str(exc))
|
|||
|
|
|
|||
|
|
_log_audit(current.username, "policy_save", "", {"mode": mode})
|
|||
|
|
return RedirectResponse(url="/console/policy", status_code=303)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/policy/rollback/{version}", include_in_schema=False)
|
|||
|
|
async def policy_rollback(version: str, request: Request, current=Depends(require_admin)):
|
|||
|
|
from app.main import policy_store
|
|||
|
|
policy_store.rollback_to(version, actor=current.username)
|
|||
|
|
_log_audit(current.username, "policy_rollback", version)
|
|||
|
|
return RedirectResponse(url="/console/policy", status_code=303)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 任务历史
|
|||
|
|
# =====================================================================
|
|||
|
|
|
|||
|
|
@router.get("/tasks", response_class=HTMLResponse, include_in_schema=False)
|
|||
|
|
async def tasks_page(request: Request, current=Depends(require_login)):
|
|||
|
|
"""任务历史 —— 每页 50 条分页。"""
|
|||
|
|
from sqlalchemy import func
|
|||
|
|
|
|||
|
|
PAGE_SIZE = 50
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
page = max(1, int(request.query_params.get("page", "1")))
|
|||
|
|
except (TypeError, ValueError):
|
|||
|
|
page = 1
|
|||
|
|
|
|||
|
|
db: Session = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
total = db.query(func.count(ScanTask.id)).scalar() or 0
|
|||
|
|
total_pages = max(1, (total + PAGE_SIZE - 1) // PAGE_SIZE)
|
|||
|
|
# 越界时回退到最后一页
|
|||
|
|
if page > total_pages:
|
|||
|
|
page = total_pages
|
|||
|
|
offset = (page - 1) * PAGE_SIZE
|
|||
|
|
|
|||
|
|
tasks = (
|
|||
|
|
db.query(ScanTask)
|
|||
|
|
.order_by(ScanTask.created_at.desc())
|
|||
|
|
.limit(PAGE_SIZE)
|
|||
|
|
.offset(offset)
|
|||
|
|
.all()
|
|||
|
|
)
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
# 分页导航页码(当前页前后各 2 页)
|
|||
|
|
lo = max(1, page - 2)
|
|||
|
|
hi = min(total_pages, page + 2)
|
|||
|
|
page_nums = list(range(lo, hi + 1))
|
|||
|
|
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"tasks.html",
|
|||
|
|
{
|
|||
|
|
"request": request,
|
|||
|
|
"user": current,
|
|||
|
|
"tasks": tasks,
|
|||
|
|
"page": page,
|
|||
|
|
"page_size": PAGE_SIZE,
|
|||
|
|
"total": total,
|
|||
|
|
"total_pages": total_pages,
|
|||
|
|
"page_nums": page_nums,
|
|||
|
|
"has_prev": page > 1,
|
|||
|
|
"has_next": page < total_pages,
|
|||
|
|
"prev_page": page - 1,
|
|||
|
|
"next_page": page + 1,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =============================================================================
|
|||
|
|
# 测试靶场页面
|
|||
|
|
# =============================================================================
|
|||
|
|
|
|||
|
|
@router.get("/test", response_class=HTMLResponse, include_in_schema=False)
|
|||
|
|
async def test_page(request: Request, current=Depends(require_login)):
|
|||
|
|
"""测试靶场页面。
|
|||
|
|
|
|||
|
|
- 默认 GET → **秒开**:从 DB 查上次 channel='test_center' 的扫描结果
|
|||
|
|
- ?rescan=1 → **手动重扫**:遍历样本目录调 scanner.scan_file(自动写 DB)
|
|||
|
|
"""
|
|||
|
|
from pathlib import Path
|
|||
|
|
import json, time
|
|||
|
|
|
|||
|
|
from app.main import policy_store, scanner as _scanner
|
|||
|
|
from app.models import ScanTask, ScanFinding # noqa: F401 (relationship 懒加载需要)
|
|||
|
|
|
|||
|
|
samples_dir = Path(__file__).resolve().parent.parent / "tests" / "samples"
|
|||
|
|
index_path = samples_dir / "index.json"
|
|||
|
|
|
|||
|
|
rescan = request.query_params.get("rescan") == "1"
|
|||
|
|
samples_list: list[Path] = []
|
|||
|
|
meta_map: dict[str, dict] = {}
|
|||
|
|
policy_version = policy_store.current().version
|
|||
|
|
last_run_at = None
|
|||
|
|
|
|||
|
|
if index_path.exists():
|
|||
|
|
index = json.loads(index_path.read_text(encoding="utf-8"))
|
|||
|
|
meta_map = {s["filename"]: s for s in index.get("samples", [])}
|
|||
|
|
|
|||
|
|
# 收集所有样本文件
|
|||
|
|
for fpath in sorted(samples_dir.iterdir()):
|
|||
|
|
if not fpath.is_file():
|
|||
|
|
continue
|
|||
|
|
if fpath.name == "index.json" or fpath.name.startswith("_"):
|
|||
|
|
continue
|
|||
|
|
if fpath.suffix == ".json" and fpath.name.startswith("_scan_results"):
|
|||
|
|
continue
|
|||
|
|
samples_list.append(fpath)
|
|||
|
|
|
|||
|
|
results: list[dict] = []
|
|||
|
|
pass_cnt = fail_cnt = 0
|
|||
|
|
|
|||
|
|
# ========== 分支 A:手动重扫 ==========
|
|||
|
|
if rescan and samples_list:
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
import asyncio as _asyncio
|
|||
|
|
_loop = _asyncio.get_running_loop()
|
|||
|
|
for fpath in samples_list:
|
|||
|
|
meta = meta_map.get(fpath.name, {})
|
|||
|
|
t0 = time.perf_counter()
|
|||
|
|
outcome = await _loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda fp=fpath: _scanner.scan_file(
|
|||
|
|
file_path=str(fp),
|
|||
|
|
original_filename=fp.name,
|
|||
|
|
task_id=f"console-test-{fp.name}-{int(time.time() * 1000)}",
|
|||
|
|
channel="test_center",
|
|||
|
|
client_id="console",
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
ms = int((time.perf_counter() - t0) * 1000)
|
|||
|
|
|
|||
|
|
findings = [
|
|||
|
|
{
|
|||
|
|
"engine": fi.engine,
|
|||
|
|
"rule": fi.rule,
|
|||
|
|
"severity": fi.severity,
|
|||
|
|
"weight": fi.weight,
|
|||
|
|
"detail": fi.detail[:200],
|
|||
|
|
}
|
|||
|
|
for fi in outcome.findings
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
verdict = outcome.verdict
|
|||
|
|
expected_verdict = meta.get("expected_verdict", "")
|
|||
|
|
expected_rules = set(meta.get("expected_rules", []))
|
|||
|
|
actual_rules = set(f["rule"] for f in findings)
|
|||
|
|
expected_intercept = expected_verdict in ("reject", "malicious")
|
|||
|
|
actual_intercept = verdict in ("reject", "malicious") or (
|
|||
|
|
verdict == "error" and len(expected_rules & actual_rules) > 0
|
|||
|
|
)
|
|||
|
|
verdict_match = expected_intercept == actual_intercept
|
|||
|
|
rule_hit = (
|
|||
|
|
bool(expected_rules & actual_rules)
|
|||
|
|
if expected_rules
|
|||
|
|
else (not findings)
|
|||
|
|
)
|
|||
|
|
clamav_error = any(
|
|||
|
|
"clamav" in str(e).lower()
|
|||
|
|
for e in outcome.meta.get("engine_errors", [])
|
|||
|
|
)
|
|||
|
|
overall_ok = verdict_match and rule_hit
|
|||
|
|
verdict_label = (
|
|||
|
|
f"error (ClamAV 不可用)"
|
|||
|
|
if (verdict == "error" and clamav_error and not expected_intercept)
|
|||
|
|
else verdict
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
results.append({
|
|||
|
|
"filename": fpath.name,
|
|||
|
|
"category": meta.get("category", "未知"),
|
|||
|
|
"description": meta.get("description", ""),
|
|||
|
|
"size": fpath.stat().st_size,
|
|||
|
|
"verdict": verdict_label,
|
|||
|
|
"actual_verdict": verdict,
|
|||
|
|
"expected_verdict": expected_verdict,
|
|||
|
|
"score": outcome.score,
|
|||
|
|
"duration_ms": ms,
|
|||
|
|
"findings_count": len(findings),
|
|||
|
|
"findings": findings,
|
|||
|
|
"expected_rules": sorted(expected_rules),
|
|||
|
|
"actual_rules": sorted(actual_rules),
|
|||
|
|
"verdict_match": verdict_match,
|
|||
|
|
"rule_hit": rule_hit,
|
|||
|
|
"overall_ok": overall_ok,
|
|||
|
|
"clamav_error": clamav_error,
|
|||
|
|
"engine_errors": outcome.meta.get("engine_errors", []),
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
pass_cnt = sum(1 for r in results if r["overall_ok"])
|
|||
|
|
fail_cnt = len(results) - pass_cnt
|
|||
|
|
|
|||
|
|
# 取这批写入的最新时间
|
|||
|
|
if results:
|
|||
|
|
from sqlalchemy import func
|
|||
|
|
newest = (
|
|||
|
|
db.query(func.max(ScanTask.created_at))
|
|||
|
|
.filter(ScanTask.channel == "test_center")
|
|||
|
|
.scalar()
|
|||
|
|
)
|
|||
|
|
last_run_at = newest
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
# ========== 分支 B:秒开 —— 从 DB 读取 ==========
|
|||
|
|
elif samples_list:
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
filenames = [f.name for f in samples_list]
|
|||
|
|
# 子查询:每个 filename 取最新 id
|
|||
|
|
from sqlalchemy import desc
|
|||
|
|
|
|||
|
|
# 直接查:channel=test_center AND filename IN (...),按 filename + created_at 排序,
|
|||
|
|
# 应用层去重取最新
|
|||
|
|
tasks = (
|
|||
|
|
db.query(ScanTask)
|
|||
|
|
.filter(
|
|||
|
|
ScanTask.channel == "test_center",
|
|||
|
|
ScanTask.filename.in_(filenames),
|
|||
|
|
)
|
|||
|
|
.order_by(ScanTask.filename, desc(ScanTask.created_at))
|
|||
|
|
.all()
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# 每个 filename 取第一条(最新)
|
|||
|
|
latest_map: dict[str, ScanTask] = {}
|
|||
|
|
for t in tasks:
|
|||
|
|
if t.filename not in latest_map:
|
|||
|
|
latest_map[t.filename] = t
|
|||
|
|
|
|||
|
|
last_run_at = (
|
|||
|
|
max((t.created_at for t in latest_map.values()), default=None)
|
|||
|
|
or (db.query(ScanTask.created_at)
|
|||
|
|
.filter(ScanTask.channel == "test_center")
|
|||
|
|
.order_by(desc(ScanTask.created_at))
|
|||
|
|
.first() or (None,))[0]
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
for fpath in samples_list:
|
|||
|
|
meta = meta_map.get(fpath.name, {})
|
|||
|
|
task = latest_map.get(fpath.name)
|
|||
|
|
|
|||
|
|
expected_verdict = meta.get("expected_verdict", "")
|
|||
|
|
expected_rules = set(meta.get("expected_rules", []))
|
|||
|
|
|
|||
|
|
if task is None:
|
|||
|
|
# DB 还没这条记录 —— 标记为"从未测试"
|
|||
|
|
results.append({
|
|||
|
|
"filename": fpath.name,
|
|||
|
|
"category": meta.get("category", "未知"),
|
|||
|
|
"description": meta.get("description", ""),
|
|||
|
|
"size": fpath.stat().st_size,
|
|||
|
|
"verdict": "pending",
|
|||
|
|
"actual_verdict": "pending",
|
|||
|
|
"expected_verdict": expected_verdict,
|
|||
|
|
"score": 0,
|
|||
|
|
"duration_ms": 0,
|
|||
|
|
"findings_count": 0,
|
|||
|
|
"findings": [],
|
|||
|
|
"expected_rules": sorted(expected_rules),
|
|||
|
|
"actual_rules": [],
|
|||
|
|
"verdict_match": False,
|
|||
|
|
"rule_hit": False,
|
|||
|
|
"overall_ok": False,
|
|||
|
|
"clamav_error": False,
|
|||
|
|
"engine_errors": [],
|
|||
|
|
"never_run": True,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 用 DB 里的 ScanTask + ScanFindings 组装
|
|||
|
|
findings = [
|
|||
|
|
{
|
|||
|
|
"engine": fi.engine,
|
|||
|
|
"rule": fi.rule,
|
|||
|
|
"severity": fi.severity,
|
|||
|
|
"weight": fi.weight,
|
|||
|
|
"detail": fi.detail[:200],
|
|||
|
|
}
|
|||
|
|
for fi in task.findings
|
|||
|
|
]
|
|||
|
|
|
|||
|
|
verdict = task.verdict
|
|||
|
|
actual_rules = set(f["rule"] for f in findings)
|
|||
|
|
expected_intercept = expected_verdict in ("reject", "malicious")
|
|||
|
|
actual_intercept = verdict in ("reject", "malicious") or (
|
|||
|
|
verdict == "error" and len(expected_rules & actual_rules) > 0
|
|||
|
|
)
|
|||
|
|
verdict_match = expected_intercept == actual_intercept
|
|||
|
|
rule_hit = (
|
|||
|
|
bool(expected_rules & actual_rules)
|
|||
|
|
if expected_rules
|
|||
|
|
else (not findings)
|
|||
|
|
)
|
|||
|
|
clamav_error = any(
|
|||
|
|
"clamav" in str(e).lower()
|
|||
|
|
for e in (task.detail.get("engine_errors", []) if task.detail else [])
|
|||
|
|
)
|
|||
|
|
overall_ok = verdict_match and rule_hit
|
|||
|
|
verdict_label = (
|
|||
|
|
f"error (ClamAV 不可用)"
|
|||
|
|
if (verdict == "error" and clamav_error and not expected_intercept)
|
|||
|
|
else verdict
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
results.append({
|
|||
|
|
"filename": fpath.name,
|
|||
|
|
"category": meta.get("category", "未知"),
|
|||
|
|
"description": meta.get("description", ""),
|
|||
|
|
"size": task.size or fpath.stat().st_size,
|
|||
|
|
"verdict": verdict_label,
|
|||
|
|
"actual_verdict": verdict,
|
|||
|
|
"expected_verdict": expected_verdict,
|
|||
|
|
"score": task.score or 0,
|
|||
|
|
"duration_ms": task.duration_ms or 0,
|
|||
|
|
"findings_count": len(findings),
|
|||
|
|
"findings": findings,
|
|||
|
|
"expected_rules": sorted(expected_rules),
|
|||
|
|
"actual_rules": sorted(actual_rules),
|
|||
|
|
"verdict_match": verdict_match,
|
|||
|
|
"rule_hit": rule_hit,
|
|||
|
|
"overall_ok": overall_ok,
|
|||
|
|
"clamav_error": clamav_error,
|
|||
|
|
"engine_errors": task.detail.get("engine_errors", []) if task.detail else [],
|
|||
|
|
"task_policy_version": task.policy_version,
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
pass_cnt = sum(1 for r in results if r["overall_ok"])
|
|||
|
|
fail_cnt = len(results) - pass_cnt
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"test_center.html",
|
|||
|
|
{
|
|||
|
|
"request": request,
|
|||
|
|
"user": current,
|
|||
|
|
"results": results,
|
|||
|
|
"total": len(results),
|
|||
|
|
"pass_count": pass_cnt,
|
|||
|
|
"fail_count": fail_cnt,
|
|||
|
|
"samples_dir": str(samples_dir),
|
|||
|
|
"policy_version": policy_version,
|
|||
|
|
"last_run_at": last_run_at,
|
|||
|
|
"rescan": rescan,
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =============================================================================
|
|||
|
|
# 测试靶场 —— 自定义文件上传扫描
|
|||
|
|
# =============================================================================
|
|||
|
|
|
|||
|
|
@router.post("/test/upload", include_in_schema=False)
|
|||
|
|
async def test_upload(
|
|||
|
|
request: Request,
|
|||
|
|
files: List[UploadFile] = File(...),
|
|||
|
|
current=Depends(require_login),
|
|||
|
|
):
|
|||
|
|
"""接收用户上传的自定义文件,即时扫描,返回 JSON 结果。
|
|||
|
|
|
|||
|
|
支持单文件或多文件批量上传。临时文件扫描后自动清理。
|
|||
|
|
"""
|
|||
|
|
import os
|
|||
|
|
import tempfile
|
|||
|
|
import uuid
|
|||
|
|
|
|||
|
|
from app.main import scanner # 应用级单例 scanner
|
|||
|
|
|
|||
|
|
results: List[Dict[str, Any]] = []
|
|||
|
|
|
|||
|
|
for f in files:
|
|||
|
|
filename = f.filename or "unnamed"
|
|||
|
|
suffix = os.path.splitext(filename)[1] or ""
|
|||
|
|
task_id = f"console-upload-{uuid.uuid4().hex[:12]}"
|
|||
|
|
|
|||
|
|
# 1) 保存到临时文件
|
|||
|
|
try:
|
|||
|
|
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|||
|
|
content = await f.read()
|
|||
|
|
if not content:
|
|||
|
|
results.append({
|
|||
|
|
"filename": filename,
|
|||
|
|
"size": 0,
|
|||
|
|
"verdict": "error",
|
|||
|
|
"verdict_cn": verdict_label("error"),
|
|||
|
|
"verdict_badge": verdict_badge("error"),
|
|||
|
|
"score": 0,
|
|||
|
|
"reason": "空文件",
|
|||
|
|
"findings": [],
|
|||
|
|
"duration_ms": 0,
|
|||
|
|
"ok": False,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
tmp.write(content)
|
|||
|
|
tmp_path = tmp.name
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
results.append({
|
|||
|
|
"filename": filename,
|
|||
|
|
"size": 0,
|
|||
|
|
"verdict": "error",
|
|||
|
|
"verdict_cn": verdict_label("error"),
|
|||
|
|
"verdict_badge": verdict_badge("error"),
|
|||
|
|
"score": 0,
|
|||
|
|
"reason": f"文件接收失败: {exc}",
|
|||
|
|
"findings": [],
|
|||
|
|
"duration_ms": 0,
|
|||
|
|
"ok": False,
|
|||
|
|
})
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 2) 扫描(放线程池,不阻塞 event loop)
|
|||
|
|
try:
|
|||
|
|
import asyncio as _asyncio
|
|||
|
|
_loop = _asyncio.get_running_loop()
|
|||
|
|
outcome = await _loop.run_in_executor(
|
|||
|
|
None,
|
|||
|
|
lambda: scanner.scan_file(
|
|||
|
|
file_path=tmp_path,
|
|||
|
|
original_filename=filename,
|
|||
|
|
task_id=task_id,
|
|||
|
|
channel="console_upload",
|
|||
|
|
client_id=current.username,
|
|||
|
|
),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
findings_list = []
|
|||
|
|
for fi in outcome.findings:
|
|||
|
|
findings_list.append({
|
|||
|
|
"engine": fi.engine,
|
|||
|
|
"rule": fi.rule,
|
|||
|
|
"severity": fi.severity,
|
|||
|
|
"weight": fi.weight,
|
|||
|
|
"detail": fi.detail[:300],
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
results.append({
|
|||
|
|
"filename": filename,
|
|||
|
|
"size": outcome.file_info.get("size", 0),
|
|||
|
|
"verdict": outcome.verdict,
|
|||
|
|
"verdict_cn": verdict_label(outcome.verdict),
|
|||
|
|
"verdict_badge": verdict_badge(outcome.verdict),
|
|||
|
|
"action": outcome.action,
|
|||
|
|
"score": outcome.score,
|
|||
|
|
"reason": outcome.reason,
|
|||
|
|
"findings": findings_list,
|
|||
|
|
"duration_ms": outcome.duration_ms,
|
|||
|
|
"ok": outcome.verdict != "error",
|
|||
|
|
"clamav_error": any(
|
|||
|
|
"clamav" in str(e).lower()
|
|||
|
|
for e in outcome.meta.get("engine_errors", [])
|
|||
|
|
),
|
|||
|
|
"engine_errors": outcome.meta.get("engine_errors", []),
|
|||
|
|
"sha256": outcome.file_info.get("sha256", ""),
|
|||
|
|
})
|
|||
|
|
finally:
|
|||
|
|
# 3) 清理临时文件
|
|||
|
|
try:
|
|||
|
|
os.unlink(tmp_path)
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return JSONResponse({"results": results})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =============================================================================
|
|||
|
|
# ClamAV 病毒库管理
|
|||
|
|
# =============================================================================
|
|||
|
|
|
|||
|
|
CLAMAV_DB_DIR = "/var/lib/clamav"
|
|||
|
|
CLAMAV_ALLOWED_EXTS = {".cvd", ".cld", ".ign2"}
|
|||
|
|
CLAMAV_MAX_UPLOAD = 300 * 1024 * 1024 # 300MB 总大小
|
|||
|
|
|
|||
|
|
# 允许覆盖的签名库文件名(clamd 定期 SelfCheck 会自动热加载)
|
|||
|
|
CLAMAV_DB_FILES = {
|
|||
|
|
"main.cvd", "main.cvd.sign",
|
|||
|
|
"daily.cvd", "daily.cvd.sign",
|
|||
|
|
"bytecode.cvd", "bytecode.cvd.sign",
|
|||
|
|
# 增量 diff 文件(可选)
|
|||
|
|
"daily.ndb", "daily.ndb.sign",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/clamav/info", include_in_schema=False)
|
|||
|
|
async def clamav_info(current=Depends(require_login)):
|
|||
|
|
"""返回 ClamAV 病毒库详情(给前端按钮点击后刷新用)。"""
|
|||
|
|
import socket as _socket
|
|||
|
|
|
|||
|
|
result: Dict[str, Any] = {
|
|||
|
|
"daemon_ok": False,
|
|||
|
|
"daemon_version": "",
|
|||
|
|
"signature_date": "",
|
|||
|
|
"signature_days_old": None,
|
|||
|
|
"files": [],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 1) clamd 连通性 + 版本
|
|||
|
|
try:
|
|||
|
|
from app.config import get_settings
|
|||
|
|
s = get_settings()
|
|||
|
|
with _socket.create_connection((s.clamav_host, s.clamav_port), timeout=5) as sock:
|
|||
|
|
sock.sendall(b"zPING\0")
|
|||
|
|
pong = sock.recv(100).rstrip(b"\0").decode()
|
|||
|
|
result["daemon_ok"] = (pong.upper() == "PONG")
|
|||
|
|
with _socket.create_connection((s.clamav_host, s.clamav_port), timeout=5) as sock:
|
|||
|
|
sock.sendall(b"zVERSION\0")
|
|||
|
|
ver = sock.recv(500).rstrip(b"\0").decode()
|
|||
|
|
result["daemon_version"] = ver
|
|||
|
|
# 解析签名库日期:ClamAV 1.5.4/28166/Tue Oct 27 14:24:12 2026
|
|||
|
|
parts = ver.split("/")
|
|||
|
|
if len(parts) >= 3:
|
|||
|
|
date_str = parts[-1].strip()
|
|||
|
|
try:
|
|||
|
|
from datetime import datetime as _dt, timezone as _tz, timedelta as _td
|
|||
|
|
sig_dt = _dt.strptime(date_str, "%a %b %d %H:%M:%S %Y").replace(tzinfo=_tz.utc)
|
|||
|
|
bj = sig_dt.astimezone(_tz(_td(hours=8)))
|
|||
|
|
result["signature_date"] = bj.strftime("%Y-%m-%d %H:%M:%S")
|
|||
|
|
result["signature_days_old"] = max(0, int((datetime.now(timezone.utc) - sig_dt).total_seconds() / 86400))
|
|||
|
|
except ValueError:
|
|||
|
|
pass
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
result["daemon_error"] = f"{type(exc).__name__}: {exc}"
|
|||
|
|
|
|||
|
|
# 2) 签名库目录文件列表
|
|||
|
|
try:
|
|||
|
|
for fname in sorted(os.listdir(CLAMAV_DB_DIR)):
|
|||
|
|
fpath = os.path.join(CLAMAV_DB_DIR, fname)
|
|||
|
|
if os.path.isfile(fpath) and (fname.endswith((".cvd", ".cld", ".ign2")) or fname.startswith("daily")):
|
|||
|
|
size = os.path.getsize(fpath)
|
|||
|
|
mtime = datetime.fromtimestamp(os.path.getmtime(fpath), tz=timezone.utc)
|
|||
|
|
result["files"].append({
|
|||
|
|
"name": fname,
|
|||
|
|
"size": size,
|
|||
|
|
"size_mb": round(size / 1024 / 1024, 1),
|
|||
|
|
"mtime": _bj_time(mtime),
|
|||
|
|
})
|
|||
|
|
except Exception as exc: # noqa: BLE001
|
|||
|
|
result["files_error"] = str(exc)
|
|||
|
|
|
|||
|
|
return JSONResponse(result)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/clamav/update", include_in_schema=False)
|
|||
|
|
async def clamav_update(current=Depends(require_login)):
|
|||
|
|
"""尝试在线更新病毒库(调用 freshclam)。
|
|||
|
|
|
|||
|
|
光闸内网环境大概率拉不到(断网隔离),会返回友好提示请使用离线上传。
|
|||
|
|
"""
|
|||
|
|
import subprocess
|
|||
|
|
|
|||
|
|
_log_audit(current.username, "clamav_update_online", "")
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
proc = subprocess.run(
|
|||
|
|
["freshclam", "--stdout"],
|
|||
|
|
capture_output=True, text=True, timeout=120,
|
|||
|
|
)
|
|||
|
|
output = (proc.stdout or "") + (proc.stderr or "")
|
|||
|
|
if proc.returncode == 0 and "OK" in output.upper():
|
|||
|
|
return JSONResponse({
|
|||
|
|
"ok": True,
|
|||
|
|
"message": "病毒库在线更新成功",
|
|||
|
|
"output": output[-500:],
|
|||
|
|
})
|
|||
|
|
# freshclam 失败(光闸内网常态)
|
|||
|
|
return JSONResponse({
|
|||
|
|
"ok": False,
|
|||
|
|
"message": "在线更新失败(光闸内网无法联网),请使用「上传签名库」离线导入",
|
|||
|
|
"output": output[-500:],
|
|||
|
|
}, status_code=200) # 200:业务失败但不是服务异常
|
|||
|
|
except FileNotFoundError:
|
|||
|
|
return JSONResponse({
|
|||
|
|
"ok": False,
|
|||
|
|
"message": "freshclam 命令不可用,请使用「上传签名库」离线导入",
|
|||
|
|
}, status_code=200)
|
|||
|
|
except subprocess.TimeoutExpired:
|
|||
|
|
return JSONResponse({
|
|||
|
|
"ok": False,
|
|||
|
|
"message": "freshclam 超时(光闸内网常态),请使用「上传签名库」离线导入",
|
|||
|
|
}, status_code=200)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/clamav/upload", include_in_schema=False)
|
|||
|
|
async def clamav_upload(
|
|||
|
|
request: Request,
|
|||
|
|
files: List[UploadFile] = File(...),
|
|||
|
|
current=Depends(require_login),
|
|||
|
|
):
|
|||
|
|
"""离线上传 ClamAV 签名库文件(main.cvd / daily.cvd / bytecode.cvd 等)。
|
|||
|
|
|
|||
|
|
保存到 /var/lib/clamav/(与 clamav 容器共享 bind mount)。
|
|||
|
|
clamd 每 600 秒 SelfCheck 会自动热加载新签名,不重启也生效。
|
|||
|
|
"""
|
|||
|
|
import hashlib
|
|||
|
|
|
|||
|
|
# ---- 先一次性读完所有内容(UploadFile.read() 后 seek(0) 在某些场景不生效)----
|
|||
|
|
file_contents: List[tuple] = [] # [(filename, content_bytes), ...]
|
|||
|
|
total_size = 0
|
|||
|
|
for f in files:
|
|||
|
|
content = await f.read()
|
|||
|
|
name = os.path.basename(f.filename or "")
|
|||
|
|
file_contents.append((name, content))
|
|||
|
|
total_size += len(content)
|
|||
|
|
|
|||
|
|
if total_size > CLAMAV_MAX_UPLOAD:
|
|||
|
|
raise HTTPException(status_code=413, detail=f"上传总大小 {total_size/1024/1024:.0f}MB 超过上限 300MB")
|
|||
|
|
|
|||
|
|
saved: List[Dict[str, Any]] = []
|
|||
|
|
skipped: List[str] = []
|
|||
|
|
errors: List[str] = []
|
|||
|
|
|
|||
|
|
for filename, content in file_contents:
|
|||
|
|
if not filename:
|
|||
|
|
skipped.append("空文件名")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 1) 扩展名校验
|
|||
|
|
ext = os.path.splitext(filename)[1].lower()
|
|||
|
|
if ext not in CLAMAV_ALLOWED_EXTS:
|
|||
|
|
errors.append(f"{filename}: 扩展名 {ext} 不被允许(仅接受 .cvd / .cld / .ign2)")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 2) 安全:拒绝带路径穿越的文件名
|
|||
|
|
if "/" in filename or "\\" in filename or ".." in filename:
|
|||
|
|
errors.append(f"{filename}: 非法文件名")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 3) 空文件
|
|||
|
|
if not content:
|
|||
|
|
skipped.append(f"{filename}: 空文件")
|
|||
|
|
continue
|
|||
|
|
|
|||
|
|
# 4) 保存
|
|||
|
|
dest = os.path.join(CLAMAV_DB_DIR, filename)
|
|||
|
|
try:
|
|||
|
|
# 备份旧文件(如有)
|
|||
|
|
if os.path.exists(dest):
|
|||
|
|
bak = dest + ".bak"
|
|||
|
|
try:
|
|||
|
|
os.replace(dest, bak)
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
with open(dest, "wb") as out:
|
|||
|
|
out.write(content)
|
|||
|
|
|
|||
|
|
sha = hashlib.sha256(content).hexdigest()
|
|||
|
|
size_mb = round(len(content) / 1024 / 1024, 1)
|
|||
|
|
saved.append({
|
|||
|
|
"name": filename,
|
|||
|
|
"size_mb": size_mb,
|
|||
|
|
"sha256": sha[:16] + "...",
|
|||
|
|
})
|
|||
|
|
except OSError as exc:
|
|||
|
|
errors.append(f"{filename}: 保存失败 - {exc}")
|
|||
|
|
|
|||
|
|
_log_audit(current.username, "clamav_upload", "", {"saved": len(saved), "files": [s["name"] for s in saved]})
|
|||
|
|
|
|||
|
|
# 5) 发 SIGHUP 给 clamd 触发立即 reload(尝试,失败不影响)
|
|||
|
|
reload_msg = "clamd 每 600 秒自动热加载新签名"
|
|||
|
|
try:
|
|||
|
|
# 通过 clamd 自己的 RELOADSCAN 命令或 SIGHUP
|
|||
|
|
import signal as _signal
|
|||
|
|
import subprocess as _sp
|
|||
|
|
proc = _sp.run(["sh", "-c", "kill -HUP $(pidof clamd) 2>/dev/null; echo ok"],
|
|||
|
|
capture_output=True, text=True, timeout=5)
|
|||
|
|
if proc.returncode == 0:
|
|||
|
|
reload_msg = "已发送 SIGHUP 给 clamd,签名库立即生效"
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return JSONResponse({
|
|||
|
|
"ok": len(saved) > 0,
|
|||
|
|
"saved": saved,
|
|||
|
|
"skipped": skipped,
|
|||
|
|
"errors": errors,
|
|||
|
|
"reload_message": reload_msg,
|
|||
|
|
"message": f"成功导入 {len(saved)} 个签名文件,{reload_msg}",
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 诊断套件 — 页面路由
|
|||
|
|
# =====================================================================
|
|||
|
|
|
|||
|
|
@router.get("/diagnostics", response_class=HTMLResponse, include_in_schema=False)
|
|||
|
|
async def diagnostics_page(request: Request, current=Depends(require_login)):
|
|||
|
|
"""诊断面板页面(登录后访问)。"""
|
|||
|
|
from app.diagnostic.scheduler import get_diag_state
|
|||
|
|
from app.diagnostic.docker_probe import status_message
|
|||
|
|
from app.diagnostic.config import get_diag_config
|
|||
|
|
|
|||
|
|
cfg = get_diag_config()
|
|||
|
|
state = get_diag_state()
|
|||
|
|
docker_msg = status_message(state.get("docker_status", "none"))
|
|||
|
|
|
|||
|
|
# 页面必跑的轻量规则
|
|||
|
|
page_rules: List[Dict[str, Any]] = []
|
|||
|
|
cached_rules: List[Dict[str, Any]] = []
|
|||
|
|
if cfg.enabled:
|
|||
|
|
try:
|
|||
|
|
from app.main import scanner, policy_store
|
|||
|
|
from app.diagnostic.engine import DiagnosticEngine
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
eng = DiagnosticEngine(
|
|||
|
|
db=db, scanner=scanner, policy_store=policy_store,
|
|||
|
|
docker_status=state.get("docker_status", "none"),
|
|||
|
|
)
|
|||
|
|
page_results = eng.run_page_request_rules()
|
|||
|
|
page_rules = [r.to_dict() for r in page_results]
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
page_rules = [{"id": "ERR", "severity": "info",
|
|||
|
|
"message": f"诊断引擎异常: {e}",
|
|||
|
|
"run_at": datetime.now(timezone.utc).isoformat()}]
|
|||
|
|
|
|||
|
|
# 缓存规则(来自定时器)
|
|||
|
|
cache = state.get("rules_cache", {})
|
|||
|
|
for rid, entry in cache.items():
|
|||
|
|
r = entry.get("result")
|
|||
|
|
if r and isinstance(r, dict):
|
|||
|
|
cached_rules.append(r)
|
|||
|
|
elif r:
|
|||
|
|
cached_rules.append(r.to_dict() if hasattr(r, "to_dict") else r)
|
|||
|
|
|
|||
|
|
# 合并所有规则(去重,page 优先覆盖 cache)
|
|||
|
|
merged: Dict[str, Dict[str, Any]] = {}
|
|||
|
|
for r in cached_rules:
|
|||
|
|
merged[r.get("id", "")] = r
|
|||
|
|
for r in page_rules:
|
|||
|
|
merged[r.get("id", "")] = r
|
|||
|
|
all_rules = sorted(merged.values(), key=lambda x: x.get("id", ""))
|
|||
|
|
|
|||
|
|
# 引擎健康(直接从 scanner.health() 拿)
|
|||
|
|
engine_health: Dict[str, Any] = {}
|
|||
|
|
try:
|
|||
|
|
from app.main import scanner as _scanner
|
|||
|
|
engine_health = _scanner.health()
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
# 容器状态(来自 Docker + 持久化重启事件)
|
|||
|
|
docker_containers: List[Dict[str, Any]] = []
|
|||
|
|
|
|||
|
|
# 先读持久化重启事件,按容器聚合计数
|
|||
|
|
restart_events_by_container: Dict[str, int] = {}
|
|||
|
|
try:
|
|||
|
|
from app.diagnostic.storage import load_restart_events
|
|||
|
|
events = load_restart_events(days=7)
|
|||
|
|
for ev in events:
|
|||
|
|
c = ev.get("container", "")
|
|||
|
|
if c:
|
|||
|
|
restart_events_by_container[c] = restart_events_by_container.get(c, 0) + 1
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
from app.diagnostic.docker_probe import get_client
|
|||
|
|
from datetime import timezone as _tz, timedelta as _td
|
|||
|
|
dc = get_client()
|
|||
|
|
if dc:
|
|||
|
|
now = datetime.now(_tz.utc)
|
|||
|
|
for c in dc.containers.list(all=True):
|
|||
|
|
attrs = c.attrs or {}
|
|||
|
|
state_d = attrs.get("State", {})
|
|||
|
|
started_raw = state_d.get("StartedAt", "")
|
|||
|
|
uptime = "未知"
|
|||
|
|
if started_raw:
|
|||
|
|
try:
|
|||
|
|
s = started_raw.replace("Z", "+00:00")
|
|||
|
|
if "." in s:
|
|||
|
|
head, frac = s.split(".", 1)
|
|||
|
|
tz_idx = frac.rfind("+")
|
|||
|
|
if tz_idx >= 0:
|
|||
|
|
frac_ns = frac[:tz_idx]
|
|||
|
|
tz = frac[tz_idx:]
|
|||
|
|
frac_ns = (frac_ns + "000000")[:6]
|
|||
|
|
s = head + "." + frac_ns + tz
|
|||
|
|
st = datetime.fromisoformat(s)
|
|||
|
|
secs = (now - st).total_seconds()
|
|||
|
|
if secs >= 0:
|
|||
|
|
days, rem = divmod(int(secs), 86400)
|
|||
|
|
hours, rem = divmod(rem, 3600)
|
|||
|
|
mins, _ = divmod(rem, 60)
|
|||
|
|
if days > 0:
|
|||
|
|
uptime = f"{days}d {hours}h"
|
|||
|
|
elif hours > 0:
|
|||
|
|
uptime = f"{hours}h {mins}m"
|
|||
|
|
else:
|
|||
|
|
uptime = f"{mins}m"
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
docker_containers.append({
|
|||
|
|
"name": c.name,
|
|||
|
|
"status": state_d.get("Status", "unknown"),
|
|||
|
|
"health": state_d.get("Health", {}).get("Status", ""),
|
|||
|
|
"restart_count": attrs.get("RestartCount", 0),
|
|||
|
|
"historical_restart_count": restart_events_by_container.get(c.name, 0),
|
|||
|
|
"oom_killed": state_d.get("OOMKilled", False),
|
|||
|
|
"uptime": uptime,
|
|||
|
|
})
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"diagnostics.html",
|
|||
|
|
{
|
|||
|
|
"request": request,
|
|||
|
|
"user": current,
|
|||
|
|
"nav_active": "diagnostics",
|
|||
|
|
"diag_enabled": cfg.enabled,
|
|||
|
|
"docker_status": state.get("docker_status", "none"),
|
|||
|
|
"docker_status_msg": docker_msg,
|
|||
|
|
"docker_info": state.get("docker_info", {}),
|
|||
|
|
"started_at": state.get("started_at", ""),
|
|||
|
|
"page_rules": page_rules,
|
|||
|
|
"cached_rules": cached_rules,
|
|||
|
|
"all_rules": all_rules,
|
|||
|
|
"engine_health": engine_health,
|
|||
|
|
"docker_containers": docker_containers,
|
|||
|
|
"refresh_time": datetime.now(timezone(timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S"),
|
|||
|
|
"badge_for": lambda sev: _diag_badge_for(sev),
|
|||
|
|
"rule_count": len(all_rules),
|
|||
|
|
"ok_count": sum(1 for r in all_rules if r.get("severity") == "ok"),
|
|||
|
|
"warn_count": sum(1 for r in all_rules if r.get("severity") in ("medium", "low")),
|
|||
|
|
"crit_count": sum(1 for r in all_rules if r.get("severity") == "high"),
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _diag_badge_for(severity: str) -> str:
|
|||
|
|
"""规则 severity → 徽章 CSS 类。"""
|
|||
|
|
return {
|
|||
|
|
"high": "tag bad",
|
|||
|
|
"medium": "tag warn",
|
|||
|
|
"low": "tag notice",
|
|||
|
|
"info": "tag muted",
|
|||
|
|
"ok": "tag ok",
|
|||
|
|
}.get(severity, "tag plain")
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 关于页 — 产品介绍(纯静态,不查数据库)
|
|||
|
|
# =====================================================================
|
|||
|
|
|
|||
|
|
@router.get("/about", response_class=HTMLResponse, include_in_schema=False)
|
|||
|
|
async def about_page(request: Request, current=Depends(require_login)):
|
|||
|
|
"""关于 MeshSafe 产品介绍页(登录后访问)。"""
|
|||
|
|
return templates.TemplateResponse(
|
|||
|
|
"about.html",
|
|||
|
|
{
|
|||
|
|
"request": request,
|
|||
|
|
"user": current,
|
|||
|
|
"nav_active": "about",
|
|||
|
|
},
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
# =====================================================================
|
|||
|
|
# 诊断套件 — API 路由(独立 router,挂在 /api/v1/telemetry/*)
|
|||
|
|
# =====================================================================
|
|||
|
|
diag_api_router = APIRouter(prefix="/api/v1/telemetry", tags=["telemetry"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
@diag_api_router.get("/summary")
|
|||
|
|
async def telemetry_summary(current=Depends(require_login)):
|
|||
|
|
"""页面加载时取实时面板数据。"""
|
|||
|
|
from app.diagnostic.scheduler import get_diag_state
|
|||
|
|
from app.diagnostic.config import get_diag_config
|
|||
|
|
cfg = get_diag_config()
|
|||
|
|
state = get_diag_state()
|
|||
|
|
|
|||
|
|
# 引擎状态(实时扫描,不走缓存)
|
|||
|
|
engine_health = {}
|
|||
|
|
try:
|
|||
|
|
from app.main import scanner as _scanner
|
|||
|
|
engine_health = _scanner.health() or {}
|
|||
|
|
except Exception:
|
|||
|
|
pass
|
|||
|
|
|
|||
|
|
return {
|
|||
|
|
"enabled": cfg.enabled,
|
|||
|
|
"docker_status": state.get("docker_status", "none"),
|
|||
|
|
"docker_info": state.get("docker_info", {}),
|
|||
|
|
"rules_cache_count": len(state.get("rules_cache", {})),
|
|||
|
|
"generated_at": state.get("latest", {}).get("generated_at"),
|
|||
|
|
"engine_health": engine_health,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@diag_api_router.get("/rules")
|
|||
|
|
async def telemetry_rules(current=Depends(require_login)):
|
|||
|
|
"""所有规则最新结果(带时间戳)。"""
|
|||
|
|
from app.diagnostic.scheduler import get_diag_state
|
|||
|
|
state = get_diag_state()
|
|||
|
|
# 合并缓存 + 最新
|
|||
|
|
latest = state.get("latest", {})
|
|||
|
|
rules = latest.get("rules", [])
|
|||
|
|
return {
|
|||
|
|
"enabled": state.get("enabled", False),
|
|||
|
|
"docker_status": state.get("docker_status", "none"),
|
|||
|
|
"generated_at": latest.get("generated_at"),
|
|||
|
|
"rules": [r.to_dict() if hasattr(r, "to_dict") else r for r in rules],
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@diag_api_router.get("/errors")
|
|||
|
|
async def telemetry_errors(current=Depends(require_login), limit: int = 20):
|
|||
|
|
"""最近 error 任务列表。"""
|
|||
|
|
from sqlalchemy import desc
|
|||
|
|
db = SessionLocal()
|
|||
|
|
try:
|
|||
|
|
rows = (
|
|||
|
|
db.query(ScanTask)
|
|||
|
|
.filter(ScanTask.verdict == "error")
|
|||
|
|
.order_by(desc(ScanTask.created_at))
|
|||
|
|
.limit(limit)
|
|||
|
|
.all()
|
|||
|
|
)
|
|||
|
|
return {
|
|||
|
|
"count": len(rows),
|
|||
|
|
"errors": [
|
|||
|
|
{
|
|||
|
|
"id": r.id[:16] + "...",
|
|||
|
|
"filename": os.path.basename(r.filename) if r.filename else "",
|
|||
|
|
"reason": r.reason[:200] if r.reason else "",
|
|||
|
|
"score": r.score,
|
|||
|
|
"created_at": _bj_time(r.created_at),
|
|||
|
|
"channel": r.channel,
|
|||
|
|
}
|
|||
|
|
for r in rows
|
|||
|
|
],
|
|||
|
|
}
|
|||
|
|
finally:
|
|||
|
|
db.close()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@diag_api_router.get("/docker-status")
|
|||
|
|
async def telemetry_docker_status(current=Depends(require_login)):
|
|||
|
|
"""Docker 能力检测结果。"""
|
|||
|
|
from app.diagnostic.scheduler import get_diag_state
|
|||
|
|
from app.diagnostic.docker_probe import status_message
|
|||
|
|
state = get_diag_state()
|
|||
|
|
return {
|
|||
|
|
"status": state.get("docker_status", "none"),
|
|||
|
|
"status_message": status_message(state.get("docker_status", "none")),
|
|||
|
|
"info": state.get("docker_info", {}),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@diag_api_router.get("/config")
|
|||
|
|
async def telemetry_config(current=Depends(require_login)):
|
|||
|
|
"""当前诊断套件配置快照。"""
|
|||
|
|
from app.diagnostic.config import get_diag_config
|
|||
|
|
return get_diag_config().to_dict()
|
|||
|
|
|
|||
|
|
|
|||
|
|
@diag_api_router.get("/dump")
|
|||
|
|
async def telemetry_dump(current=Depends(require_login)):
|
|||
|
|
"""一键导出 tar.gz 诊断包。"""
|
|||
|
|
from fastapi.responses import StreamingResponse
|
|||
|
|
from app.diagnostic.dumper import build_diagnostics_package
|
|||
|
|
|
|||
|
|
try:
|
|||
|
|
data = build_diagnostics_package()
|
|||
|
|
except Exception as e: # noqa: BLE001
|
|||
|
|
import logging
|
|||
|
|
logging.exception("导出诊断包失败")
|
|||
|
|
raise HTTPException(status_code=500, detail=f"导出失败: {e}")
|
|||
|
|
|
|||
|
|
fname = f"diagnostics-{datetime.now().strftime('%Y%m%d-%H%M%S')}.tar.gz"
|
|||
|
|
return StreamingResponse(
|
|||
|
|
iter([data]),
|
|||
|
|
media_type="application/gzip",
|
|||
|
|
headers={
|
|||
|
|
"Content-Disposition": f'attachment; filename="{fname}"',
|
|||
|
|
"Content-Length": str(len(data)),
|
|||
|
|
},
|
|||
|
|
)
|