Files
MeshSate/app/diagnostic/storage.py
zhaolei 16f134e328 feat: MeshSafe 文件摆渡安全卫士 v1.0 初始提交
核心功能:
- 6 层检测管线: L0 结构分析 → L1 ClamAV → L2 文档深度检测 → L3 关键词匹配 → L4 YARA → L5 PE/二进制
- FastAPI + PostgreSQL + Redis + Docker Compose 架构
- 客户端 HMAC-SHA256 签名鉴权 + 限流 + 幂等性
- 控制台 UI (登录/Dashboard/诊断中心/策略管理/任务列表/测试中心/产品介绍页)
- 三层诊断定时器 (error_scan 30s / db_aggregate 30s / container_check 5min)

并发与稳定性修复:
- 诊断定时器 Redis 时间槽位锁 (多 worker 同一槽位只有一个执行)
- JSONL 存储 fcntl 跨进程排他锁 + 容错解码 + 原子重写
- 移除 SQLite fallback, 统一 PostgreSQL
- API_CLIENTS 脱敏为环境变量, .env 已 .gitignore
2026-10-28 19:07:11 +08:00

456 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""诊断套件持久化存储模块。
职责:
- 定时器规则结果 → JSON Lines 追加(rules_history.jsonl)
- Docker 日志快照 → 存文件(logs/*.log)
- 自动清理过期数据(基于配置的 history_retention_days)
"""
from __future__ import annotations
import json
import logging
import os
import time
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from app.diagnostic.config import get_diag_config
logger = logging.getLogger(__name__)
def _storage_dir() -> Path:
"""获取存储根目录。"""
cfg = get_diag_config()
p = Path(cfg.storage_dir)
p.mkdir(parents=True, exist_ok=True)
return p
def _logs_dir() -> Path:
p = _storage_dir() / "logs"
p.mkdir(parents=True, exist_ok=True)
return p
def _rules_history_file() -> Path:
return _storage_dir() / "rules_history.jsonl"
# ======================================================================
# 跨进程文件锁写入
#
# 多 worker 部署时,每个 worker 都会跑诊断定时器。"a" 追加模式在多进程
# 下不安全——各进程独立维护文件偏移量,后写的会覆盖先写的内容,把一行
# JSON 从中间劈开,产出非法 UTF-8 字节,导致后续读取全部报错。
# 这里用 fcntl 文件锁保证同一时刻只有一个进程在写。
# ======================================================================
try:
import fcntl # type: ignore
_HAS_FCNTL = True
except ImportError: # pragma: no cover - Windows 平台
fcntl = None # type: ignore
_HAS_FCNTL = False
def _append_line_locked(path: Path, line: str) -> None:
"""向文件追加一行,跨进程互斥。
Linux 下用 fcntl.flock 排他锁;无 fcntl 时退化为进程内线程锁。
"""
payload = line + "\n"
with open(path, "a", encoding="utf-8") as fp:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_EX)
try:
fp.seek(0, os.SEEK_END)
fp.write(payload)
fp.flush()
os.fsync(fp.fileno())
finally:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
def _rewrite_file_locked(path: Path, lines: List[str]) -> None:
"""整文件重写(清理过期数据用),同样走文件锁。
写临时文件再原子替换,避免重写过程中其他进程读到半截内容。
"""
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fp:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_EX)
try:
for line in lines:
fp.write(line + "\n")
fp.flush()
os.fsync(fp.fileno())
finally:
if _HAS_FCNTL:
fcntl.flock(fp.fileno(), fcntl.LOCK_UN)
os.replace(tmp, path)
# ======================================================================
# 规则历史:JSON Lines 追加
# ======================================================================
def append_rule_results(
rules: List[Any],
timer_name: str,
) -> None:
"""定时器每次跑完追加一行 JSONL。
每行格式::
{"ts": "2026-10-28T13:12:14", "timer": "error_scan",
"docker_status": "full",
"rules": [{"id":"R001","severity":"ok","message":"...","run_at":"...","run_by":"timer:error_scan",...}]}
"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return
try:
# 北京时间
bj = timezone(timedelta(hours=8))
entry: Dict[str, Any] = {
"ts": datetime.now(bj).strftime("%Y-%m-%d %H:%M:%S"),
"timer": timer_name,
"docker_status": None,
"rules": [
r.to_dict() if hasattr(r, "to_dict") else dict(r)
for r in rules
],
}
f = _rules_history_file()
_append_line_locked(f, json.dumps(entry, ensure_ascii=False))
# 顺带清理过期
_trim_history()
except Exception as e: # noqa: BLE001
logger.warning("诊断持久化写入失败: %s", e)
def load_rule_history(days: Optional[int] = None) -> List[Dict[str, Any]]:
"""读取规则历史 JSONL,返回最近 N 天的行。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return []
days = days or cfg.history_retention_days
f = _rules_history_file()
if not f.exists():
return []
cutoff = datetime.now() - timedelta(days=days)
results: List[Dict[str, Any]] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
for line in fp:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
# 按 ts 判断是否过期
ts_str = obj.get("ts", "")
try:
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
results.append(obj)
except ValueError:
# 时间格式不对就不过滤,保留
results.append(obj)
except Exception as e: # noqa: BLE001
logger.warning("诊断历史读取失败: %s", e)
return results
def _trim_history() -> None:
"""清理过期的规则历史行(保留文件头)。"""
cfg = get_diag_config()
days = cfg.history_retention_days
f = _rules_history_file()
if not f.exists():
return
cutoff = datetime.now() - timedelta(days=days)
kept: List[str] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
total = 0
for line in fp:
total += 1
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
ts_str = obj.get("ts", "")
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
kept.append(line)
except Exception:
kept.append(line) # 格式异常的保留
# 如果删了一些(不是全部),就重写
if kept and len(kept) < total:
_rewrite_file_locked(f, kept)
except Exception as e: # noqa: BLE001
logger.warning("诊断历史清理失败: %s", e)
# ======================================================================
# Docker 日志快照
# ======================================================================
def snapshot_container_logs(
containers: List[str],
tail: int,
filename_prefix: Optional[str] = None,
) -> List[Path]:
"""把各容器当前 docker logs --tail N 存一份文件。
文件名: {container}-{prefix}{timestamp}.log
prefix 可选(如 timer 存的加前缀 timer-)
返回写入的文件列表。
"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return []
from app.diagnostic.docker_probe import get_client
client = get_client()
if client is None:
return []
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
prefix = f"{filename_prefix}-" if filename_prefix else ""
out: List[Path] = []
for name in containers:
try:
c = client.containers.get(name)
raw = c.logs(tail=tail)
text = (
raw.decode("utf-8", errors="replace")
if isinstance(raw, (bytes, bytearray))
else str(raw)
)
fname = f"{name}-{prefix}{ts}.log"
fpath = _logs_dir() / fname
fpath.write_text(text, encoding="utf-8")
out.append(fpath)
except Exception as e: # noqa: BLE001
logger.warning("容器 %s 日志快照失败: %s", name, e)
# 顺带清理过期日志
_trim_logs()
return out
def load_log_snapshots(containers: Optional[List[str]] = None) -> Dict[str, List[Path]]:
"""读 logs/ 目录,返回 {container_name: [path, path, ...]}。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return {}
cutoff = datetime.now() - timedelta(days=cfg.history_retention_days)
result: Dict[str, List[Path]] = {}
for f in _logs_dir().glob("*.log"):
stem = f.stem # 去掉 .log
# 文件名格式: meshsafe-api-timer-20261028-1312 或 meshsafe-api-20261028-1312
# 容器名是 stem 里第一个 - 前面的部分... 其实容器名本身也可能带 -
# 简化:直接用 stem 整体当 key(导出时按需匹配)
# 但我们需要按容器分组,所以用 glob 前缀匹配
if containers:
matched = None
for c in containers:
if stem.startswith(c + "-"):
matched = c
break
if matched is None:
continue
container_key = matched
else:
# 没指定容器,尝试从文件名推断
parts = stem.split("-")
container_key = parts[0] if parts else stem
try:
mtime = datetime.fromtimestamp(f.stat().st_mtime)
if mtime >= cutoff:
result.setdefault(container_key, []).append(f)
except Exception:
result.setdefault(container_key, []).append(f)
# 按 mtime 排序(新的在前)
for k in result:
result[k].sort(key=lambda p: p.stat().st_mtime, reverse=True)
return result
def _trim_logs() -> None:
"""清理过期的日志快照文件。"""
cfg = get_diag_config()
cutoff = datetime.now() - timedelta(days=cfg.history_retention_days)
try:
for f in _logs_dir().glob("*.log"):
try:
mtime = datetime.fromtimestamp(f.stat().st_mtime)
if mtime < cutoff:
f.unlink()
except FileNotFoundError:
pass
except Exception as e: # noqa: BLE001
logger.warning("日志快照清理失败: %s", e)
# ======================================================================
# 容器重启事件追踪
# ======================================================================
def _restart_events_file() -> Path:
return _storage_dir() / "restart_events.jsonl"
def append_restart_event(
container: str,
restart_before: int,
restart_after: int,
uptime_before: str = "",
uptime_before_seconds: float = 0,
trigger: str = "restart_count_increased",
) -> None:
"""记录一条容器重启事件到 restart_events.jsonl。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return
try:
bj = timezone(timedelta(hours=8))
entry = {
"ts": datetime.now(bj).strftime("%Y-%m-%d %H:%M:%S"),
"container": container,
"restart_before": restart_before,
"restart_after": restart_after,
"uptime_before": uptime_before,
"uptime_before_seconds": int(uptime_before_seconds),
"trigger": trigger,
}
f = _restart_events_file()
_append_line_locked(f, json.dumps(entry, ensure_ascii=False))
# 清理过期事件
_trim_restart_events()
except Exception as e: # noqa: BLE001
logger.warning("重启事件写入失败: %s", e)
def snapshot_on_restart(container: str, tail: int = 300) -> Optional[Path]:
"""容器刚重启后,抓一份当前 docker logs 片段存到 logs/*-restart-*.log。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return None
try:
from app.diagnostic.docker_probe import get_client
client = get_client()
if client is None:
return None
c = client.containers.get(container)
raw = c.logs(tail=tail)
text = (
raw.decode("utf-8", errors="replace")
if isinstance(raw, (bytes, bytearray))
else str(raw)
)
ts = datetime.now().strftime("%Y%m%d-%H%M%S")
fname = f"{container}-restart-{ts}.log"
fpath = _logs_dir() / fname
fpath.write_text(text, encoding="utf-8")
return fpath
except Exception as e: # noqa: BLE001
logger.warning("重启日志快照失败 %s: %s", container, e)
return None
def load_restart_events(days: Optional[int] = None) -> List[Dict[str, Any]]:
"""读取重启事件 JSONL,返回最近 N 天的行。"""
cfg = get_diag_config()
if not cfg.storage_enabled:
return []
days = days or cfg.history_retention_days
f = _restart_events_file()
if not f.exists():
return []
cutoff = datetime.now() - timedelta(days=days)
results: List[Dict[str, Any]] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
for line in fp:
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
except json.JSONDecodeError:
continue
ts_str = obj.get("ts", "")
try:
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
results.append(obj)
except ValueError:
results.append(obj)
except Exception as e: # noqa: BLE001
logger.warning("重启事件读取失败: %s", e)
return results
def _trim_restart_events() -> None:
"""清理过期的重启事件行。"""
cfg = get_diag_config()
days = cfg.history_retention_days
f = _restart_events_file()
if not f.exists():
return
cutoff = datetime.now() - timedelta(days=days)
kept: List[str] = []
try:
with open(f, "r", encoding="utf-8", errors="replace") as fp:
total = 0
for line in fp:
total += 1
line = line.strip()
if not line:
continue
try:
obj = json.loads(line)
ts_str = obj.get("ts", "")
ts_dt = datetime.strptime(ts_str, "%Y-%m-%d %H:%M:%S")
if ts_dt >= cutoff:
kept.append(line)
except Exception:
kept.append(line)
if kept and len(kept) < total:
_rewrite_file_locked(f, kept)
except Exception as e: # noqa: BLE001
logger.warning("重启事件清理失败: %s", e)