Files
MeshSate/app/main.py

569 lines
20 KiB
Python
Raw Normal View History

"""MeshSafe 服务入口(v1.1 — 接口对齐对外文档)。
启动方式::
uvicorn app.main:app --host 0.0.0.0 --port 8443
"""
from __future__ import annotations
import hashlib
import json
import logging
import os
import queue
import tempfile
import threading
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from fastapi import Body, Depends, FastAPI, File, Form, HTTPException, UploadFile, status
from app.config import get_settings
from app.engines.structure import StructureEngine
from app.logging_config import setup_logging
from app.security.deps import (
authenticate,
ResponseSigningMiddleware,
ClientContext,
)
from app.security.ratelimit import rate_limit
from app.services.policy import PolicyEngine, PolicyStore
from app.services.scanner import Scanner, ScanOutcome
# ---------------------------------------------------------------------------
# 启动时完成配置加载 + 日志初始化
# ---------------------------------------------------------------------------
setup_logging()
logger = logging.getLogger(__name__)
settings = get_settings()
# ---------------------------------------------------------------------------
# 策略存储 + 扫描器(应用级单例)
# ---------------------------------------------------------------------------
_policy_file = os.environ.get("MESHSAFE_POLICY_FILE", "config/policy.yaml")
policy_store = PolicyStore(policy_file=_policy_file)
scanner = Scanner(policy_store)
# ---------------------------------------------------------------------------
# 异步扫描队列(内存实现,生产可替换为 Redis)
# ---------------------------------------------------------------------------
_task_store: Dict[str, Dict[str, Any]] = {}
_task_lock = threading.Lock()
_task_queue: "queue.Queue[tuple]" = queue.Queue(maxsize=256)
_WORKER_COUNT = int(os.environ.get("MESHSAFE_WORKERS", "2"))
def _worker_loop() -> None:
"""后台 worker:从队列取任务执行扫描,结果存入 _task_store。"""
local_logger = logging.getLogger("app.worker")
while True:
try:
task_id, file_path, original_filename, channel, client_id = _task_queue.get()
except Exception:
continue
with _task_lock:
_task_store[task_id]["status"] = "running"
try:
outcome = scanner.scan_file(
file_path=file_path,
original_filename=original_filename,
task_id=task_id,
channel=channel,
client_id=client_id,
mode="async",
)
try:
os.unlink(file_path)
except OSError:
pass
with _task_lock:
_task_store[task_id].update({
"status": "completed",
"result": outcome.to_response(include_details=True),
"finished_at": time.time(),
})
local_logger.info("异步扫描完成 task_id=%s verdict=%s action=%s",
task_id, outcome.verdict, outcome.action)
except Exception as exc: # noqa: BLE001
local_logger.exception("异步扫描失败 task_id=%s", task_id)
with _task_lock:
_task_store[task_id].update({
"status": "failed",
"error": str(exc),
"finished_at": time.time(),
})
def _start_workers() -> None:
for i in range(_WORKER_COUNT):
t = threading.Thread(target=_worker_loop, name=f"scan-worker-{i}", daemon=True)
t.start()
logger.info("启动 %d 个异步扫描 worker 线程", _WORKER_COUNT)
def _file_size_limit() -> int:
"""当前策略允许的最大文件大小(字节)。"""
g = policy_store.current().raw.get("global", {})
return int(g.get("max_file_size", 1610612736))
def _sync_max_size() -> int:
"""超过这个大小自动走异步(字节)。"""
g = policy_store.current().raw.get("global", {})
return int(g.get("sync_max_size", 536870912))
app = FastAPI(
title="MeshSafe 文件安全检测服务",
version="1.1.0",
description=(
"为光闸摆渡场景设计的独立文件安全检测服务。"
'对外只做一件事:判断"这个文件能不能传进内网",'
"把检测结论交还给摆渡系统决定。"
),
)
# =====================================================================
# 全局异常处理 —— /console/* 页面 401 友好跳转登录页
# =====================================================================
from fastapi import Request
from fastapi.responses import JSONResponse, RedirectResponse
from starlette.exceptions import HTTPException as StarletteHTTPException
@app.exception_handler(HTTPException)
async def _http_exception_handler(request: Request, exc: HTTPException):
"""统一处理 HTTPException。
对于 /console/* 路由:
- 401 → 302 跳登录页(浏览器)或 JSON(API)
- 403 → 提示 admin 权限不够
对于 /api/* 路由:
- 保持 JSON 原样返回(API 调用方需要结构化错误)
"""
path = request.url.path
# 控制台路由的浏览器请求 —— 401/403 友好化
if path.startswith("/console/") and path != "/console/login":
accept = request.headers.get("accept", "")
want_html = "text/html" in accept or "*/*" in accept
if exc.status_code == 401 and want_html:
return RedirectResponse(
url="/console/login?reason=session_expired",
status_code=302,
)
if exc.status_code == 403 and want_html:
return JSONResponse(
status_code=403,
content={"detail": "需要 admin 权限才能执行此操作"},
)
# 其他情况保持默认 JSON 返回
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
# 响应签名中间件(为所有 /api/* 响应添加 HMAC 签名)
app.add_middleware(ResponseSigningMiddleware)
# 控制台路由(P2,无签名要求)
try:
from app.console import router as console_router, diag_api_router
app.include_router(console_router)
app.include_router(diag_api_router)
except Exception: # noqa: BLE001
logger.warning("控制台路由加载失败(Jinja2 未安装或 DB 不可用)", exc_info=True)
# 静态资源(logo 等)
from fastapi.staticfiles import StaticFiles as _StaticFiles
_project_root = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
_static_dir = os.path.join(_project_root, "static")
os.makedirs(_static_dir, exist_ok=True)
app.mount("/static", _StaticFiles(directory=_static_dir), name="static")
@app.on_event("startup")
async def _startup() -> None:
logger.info("MeshSafe 启动中:env=%s port=%d", settings.env, settings.port)
try:
policy = policy_store.current()
logger.info(
"策略加载成功 version=%s zero_tolerance_rules=%d macro_exceptions=%d",
policy.version,
len(policy.zero_tolerance_rules),
len(policy.macro_exceptions or []),
)
except Exception: # noqa: BLE001
logger.exception("策略加载失败,服务将处于 fail_closed 状态")
_start_workers()
# ---------- 诊断套件启动 ----------
try:
from app.diagnostic.config import get_diag_config
from app.diagnostic.docker_probe import detect_docker_access
from app.diagnostic.scheduler import start_timers
diag_cfg = get_diag_config()
if diag_cfg.enabled:
docker_info = detect_docker_access(mode=diag_cfg.docker_mode)
start_timers(docker_info["status"], docker_info)
logger.info(
"MeshSafe Diagnostics 已启用: docker=%s status=%s",
diag_cfg.docker_mode, docker_info["status"],
)
else:
logger.info("MeshSafe Diagnostics: disabled(config.enabled=false)")
except Exception: # noqa: BLE001
logger.exception("诊断套件启动失败(不影响主服务)")
# ===========================================================================
# API 路由(对外文档 v1.1)
# ===========================================================================
# ---------------------------------------------------------------------------
# 4.5 健康检查
# ---------------------------------------------------------------------------
@app.get("/api/v1/health", tags=["health"])
async def health() -> Dict[str, Any]:
"""健康检查与引擎状态(公开接口,无需签名)。"""
engines = scanner.health()
engine_map: Dict[str, Any] = {}
for name, info in engines.items():
entry = {"available": info.get("ok", info.get("available", False))}
if "version" in info:
entry["version"] = info["version"]
if "signature_date" in info:
entry["signature_updated_at"] = info["signature_date"]
if "rules_loaded" in info:
entry["rules_loaded"] = info["rules_loaded"]
if "rules_dir" in info:
entry["rules_dir"] = info["rules_dir"]
engine_map[name] = entry
with _task_lock:
pending_count = sum(1 for t in _task_store.values() if t["status"] in ("pending", "running"))
return {
"status": "healthy",
"policy_version": policy_store.current().version,
"engines": engine_map,
"queue": {
"pending": pending_count,
"running": pending_count,
},
"server_time": datetime.now(timezone.utc).isoformat(),
}
# ---------------------------------------------------------------------------
# 4.1 提交扫描(同步/异步自动切换)
# ---------------------------------------------------------------------------
@app.post("/api/v1/scans", tags=["scan"])
async def scans_submit(
file: UploadFile = File(...),
channel: str = Form("default"),
priority: str = Form("normal"),
ctx: ClientContext = Depends(authenticate),
_ = Depends(rate_limit(max_requests_per_min=60, label="scans")),
) -> Dict[str, Any]:
"""提交文件进行安全检测。
请求体相同,服务根据文件大小自动选择同步或异步:
- ≤ sync_max_size(默认 512MB)→ 同步:请求内返回完整结论
- > sync_max_size → 异步:返回 task_id,轮询 GET /api/v1/scans/{task_id}
"""
client_id = ctx.client_id
task_id = uuid.uuid4().hex
# 1) 保存到临时文件
suffix = os.path.splitext(file.filename or "")[1] or ""
try:
content = await file.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
except Exception as exc: # noqa: BLE001
logger.exception("接收上传文件失败 task_id=%s", task_id)
raise HTTPException(status_code=400, detail=f"文件接收失败: {exc}")
file_size = len(content)
del content # 尽快释放内存
# 2) 文件大小硬上限(超过直接 413)
if file_size > _file_size_limit():
try:
os.unlink(tmp_path)
except OSError:
pass
raise HTTPException(
status_code=413,
detail=f"文件大小 {file_size/1024/1024:.0f}MB 超过上限 {_file_size_limit()/1024/1024:.0f}MB",
)
# 3) 自动选择同步 / 异步
if file_size <= _sync_max_size():
# ---- 同步扫描 ----
import asyncio as _asyncio
try:
loop = _asyncio.get_running_loop()
outcome = await loop.run_in_executor(
None,
lambda: scanner.scan_file(
file_path=tmp_path,
original_filename=file.filename or f"upload{suffix}",
task_id=task_id,
channel=channel,
client_id=client_id,
),
)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
# 同步结果也存入 _task_store,支持后续 GET /api/v1/scans/{task_id} 查询和 review
with _task_lock:
_task_store[task_id] = {
"status": "completed",
"result": outcome.to_response(include_details=True),
"finished_at": time.time(),
"filename": file.filename,
"channel": channel,
}
return outcome.to_response(include_details=True)
else:
# ---- 异步扫描 ----
with _task_lock:
if len(_task_store) >= 1024:
finished = [tid for tid, t in _task_store.items()
if t["status"] in ("completed", "failed")]
for tid in finished[-512:]:
del _task_store[tid]
_task_store[task_id] = {
"status": "pending",
"submitted_at": time.time(),
"filename": file.filename,
"channel": channel,
}
try:
_task_queue.put_nowait((task_id, tmp_path, file.filename or f"upload{suffix}",
channel, client_id))
except queue.Full:
with _task_lock:
_task_store[task_id] = {"status": "failed", "error": "扫描队列已满"}
try:
os.unlink(tmp_path)
except OSError:
pass
raise HTTPException(status_code=503, detail="扫描队列已满,请稍后重试")
return {
"task_id": task_id,
"status": "pending",
"message": "任务已受理,请轮询 /api/v1/scans/{task_id} 获取结果",
}
# ---------------------------------------------------------------------------
# 4.2 查询扫描结果
# ---------------------------------------------------------------------------
@app.get("/api/v1/scans/{task_id}", tags=["scan"])
async def scans_get(task_id: str, ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""查询扫描结果。同步模式下用于重试补查,异步模式下用于轮询。"""
with _task_lock:
task = _task_store.get(task_id)
if not task:
raise HTTPException(status_code=404, detail=f"任务 {task_id} 不存在或已过期")
status_val = task["status"]
if status_val == "completed":
return task["result"]
if status_val == "failed":
return {
"task_id": task_id,
"status": "failed",
"verdict": "error",
"action": "error",
"score": 0.0,
"reason": task.get("error", "未知错误"),
"error": task.get("error"),
"finished_at": datetime.fromtimestamp(task.get("finished_at", time.time()), tz=timezone.utc).isoformat(),
}
# pending / running → 返回占位结构
return {
"task_id": task_id,
"status": status_val,
"verdict": "pending",
"action": "pending",
"score": 0.0,
"reason": "",
}
# ---------------------------------------------------------------------------
# 4.3 重新扫描
# ---------------------------------------------------------------------------
@app.post("/api/v1/scans/{task_id}/rescan", tags=["scan"])
async def scans_rescan(
task_id: str,
file: UploadFile = File(...),
ctx: ClientContext = Depends(authenticate),
) -> Dict[str, Any]:
"""使用当前生效策略重新扫描。
原始文件可能已被清理(进程退出即 unlink),因此需要重新上传文件。
"""
with _task_lock:
old_task = _task_store.get(task_id)
suffix = os.path.splitext(file.filename or "")[1] or ""
try:
content = await file.read()
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
tmp.write(content)
tmp_path = tmp.name
except Exception as exc: # noqa: BLE001
logger.exception("rescan 接收失败 task_id=%s", task_id)
raise HTTPException(status_code=400, detail=f"文件接收失败: {exc}")
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=file.filename or (old_task.get("filename", f"upload{suffix}") if old_task else f"upload{suffix}"),
task_id=task_id,
channel=old_task.get("channel", "default") if old_task else "default",
client_id=ctx.client_id,
),
)
finally:
try:
os.unlink(tmp_path)
except OSError:
pass
return outcome.to_response(include_details=True)
# ---------------------------------------------------------------------------
# 4.4 人工复核回写
# ---------------------------------------------------------------------------
@app.post("/api/v1/scans/{task_id}/review", tags=["scan"])
async def scans_review(
task_id: str,
body: Dict[str, Any] = Body(...),
ctx: ClientContext = Depends(authenticate),
) -> Dict[str, Any]:
"""回写人工复核结论。
请求体::
{ "decision": "pass" | "reject", "comment": "..." }
"""
decision = body.get("decision", "").lower()
comment = body.get("comment", "")
if decision not in ("pass", "reject"):
raise HTTPException(status_code=400, detail="decision 必须是 'pass' 或 'reject'")
if not comment.strip():
raise HTTPException(status_code=400, detail="comment 必填(审计要求)")
with _task_lock:
task = _task_store.get(task_id)
if task:
task["review_decision"] = decision
task["review_comment"] = comment
task["reviewed_by"] = ctx.client_id
task["reviewed_at"] = time.time()
return {
"task_id": task_id,
"decision": decision,
"comment": comment,
"recorded_at": datetime.now(timezone.utc).isoformat(),
"reviewed_by": ctx.client_id,
}
# ---------------------------------------------------------------------------
# 4.6 查询当前策略
# ---------------------------------------------------------------------------
@app.get("/api/v1/policies", tags=["policy"])
async def policies_get(ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""查询当前生效的全局策略(只读)。"""
policy = policy_store.current()
return {
"version": policy.version,
"sha256": policy.sha256,
"content": policy.raw,
}
# ---------------------------------------------------------------------------
# 4.7 策略热重载
# ---------------------------------------------------------------------------
@app.post("/api/v1/policies/reload", tags=["policy"])
async def policies_reload(ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""从策略文件重新加载(无需重启)。"""
try:
policy = policy_store.reload()
logger.info("策略热重载成功 version=%s", policy.version)
return {
"reloaded": True,
"version": policy.version,
"sha256": policy.sha256,
}
except Exception as exc: # noqa: BLE001
logger.exception("策略热重载失败")
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=f"策略加载失败: {exc}",
)
# ---------------------------------------------------------------------------
# 4.8 审计日志完整性校验
# ---------------------------------------------------------------------------
@app.get("/api/v1/audit/verify", tags=["audit"])
async def audit_verify(ctx: ClientContext = Depends(authenticate)) -> Dict[str, Any]:
"""校验审计日志哈希链完整性。
当前实现为 stub(哈希链待二期接入)—— 返回 intact=True 表示审计链路完好。
"""
return {
"checked": 0,
"intact": True,
"broken_at_id": None,
"verified_at": datetime.now(timezone.utc).isoformat(),
"note": "审计哈希链为二期功能,当前返回 intact=True 占位",
}
# ---------------------------------------------------------------------------
# 直接运行支持
# ---------------------------------------------------------------------------
if __name__ == "__main__":
import uvicorn
uvicorn.run("app.main:app", host=settings.host, port=settings.port, reload=False)