fix+perf: SQLite连接busy_timeout修复并发锁/静态版本号缓存/删除资产批量清理/安全检查最新记录子查询修正/AI余额与SSL探测并发化/密钥常量时间比较
This commit is contained in:
+10
-2
@@ -5,6 +5,7 @@
|
|||||||
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401。
|
- 若已配置,则要求请求头携带正确的 X-API-Key,否则返回 401。
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import hmac
|
||||||
from typing import Optional
|
from typing import Optional
|
||||||
|
|
||||||
from fastapi import Header, HTTPException, status
|
from fastapi import Header, HTTPException, status
|
||||||
@@ -12,13 +13,20 @@ from fastapi import Header, HTTPException, status
|
|||||||
from app.core.config import settings
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _key_matches(provided: Optional[str], expected: str) -> bool:
|
||||||
|
"""常量时间比较密钥,避免时序旁路泄露密钥长度/前缀信息"""
|
||||||
|
if not provided:
|
||||||
|
return False
|
||||||
|
return hmac.compare_digest(provided.encode(), expected.encode())
|
||||||
|
|
||||||
|
|
||||||
async def require_api_key(
|
async def require_api_key(
|
||||||
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
x_api_key: Optional[str] = Header(default=None, alias="X-API-Key"),
|
||||||
) -> None:
|
) -> None:
|
||||||
"""校验 API Key(可选启用)"""
|
"""校验 API Key(可选启用)"""
|
||||||
if not settings.API_KEY:
|
if not settings.API_KEY:
|
||||||
return
|
return
|
||||||
if x_api_key != settings.API_KEY:
|
if not _key_matches(x_api_key, settings.API_KEY):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="API Key 无效或缺失",
|
detail="API Key 无效或缺失",
|
||||||
@@ -31,7 +39,7 @@ async def require_agent_key(
|
|||||||
"""校验 Agent 上报 Key(可选启用)"""
|
"""校验 Agent 上报 Key(可选启用)"""
|
||||||
if not settings.AGENT_KEY:
|
if not settings.AGENT_KEY:
|
||||||
return
|
return
|
||||||
if x_agent_key != settings.AGENT_KEY:
|
if not _key_matches(x_agent_key, settings.AGENT_KEY):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Agent Key 无效或缺失",
|
detail="Agent Key 无效或缺失",
|
||||||
|
|||||||
+13
-9
@@ -16,26 +16,26 @@ DATA_DIR.mkdir(parents=True, exist_ok=True)
|
|||||||
ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
|
ASSETS_DB_URL = f"sqlite:///{DATA_DIR / 'assets.db'}"
|
||||||
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
|
METRICS_DB_URL = f"sqlite:///{DATA_DIR / 'metrics.db'}"
|
||||||
|
|
||||||
|
# timeout:sqlite3 内置 busy 等待(秒),随连接创建生效。
|
||||||
|
# SQLite 默认使用 NullPool(每请求新建连接),PRAGMA busy_timeout 只在单连接有效,
|
||||||
|
# 必须通过 connect_args 传递,否则并发写入(Agent 上报 + 清理任务)会报 database is locked。
|
||||||
assets_engine = create_engine(
|
assets_engine = create_engine(
|
||||||
ASSETS_DB_URL,
|
ASSETS_DB_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False, "timeout": 30},
|
||||||
pool_pre_ping=True,
|
|
||||||
)
|
)
|
||||||
metrics_engine = create_engine(
|
metrics_engine = create_engine(
|
||||||
METRICS_DB_URL,
|
METRICS_DB_URL,
|
||||||
echo=False,
|
echo=False,
|
||||||
connect_args={"check_same_thread": False},
|
connect_args={"check_same_thread": False, "timeout": 30},
|
||||||
pool_pre_ping=True,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _enable_wal(engine) -> None:
|
def _enable_wal(engine) -> None:
|
||||||
"""启用 WAL 模式,提升 SQLite 并发读写能力"""
|
"""启用 WAL 模式,提升 SQLite 并发读写能力(journal_mode 持久化到库文件,设置一次即可)"""
|
||||||
import sqlalchemy as sa
|
import sqlalchemy as sa
|
||||||
with engine.connect() as conn:
|
with engine.connect() as conn:
|
||||||
conn.execute(sa.text("PRAGMA journal_mode=WAL"))
|
conn.execute(sa.text("PRAGMA journal_mode=WAL"))
|
||||||
conn.execute(sa.text("PRAGMA busy_timeout=5000"))
|
|
||||||
|
|
||||||
|
|
||||||
_enable_wal(assets_engine)
|
_enable_wal(assets_engine)
|
||||||
@@ -152,12 +152,16 @@ def _backfill_provider_services(conn) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def get_session() -> Generator[Session, None, None]:
|
def get_session() -> Generator[Session, None, None]:
|
||||||
"""资产库会话(默认)"""
|
"""资产库会话(默认)
|
||||||
with Session(assets_engine) as session:
|
|
||||||
|
expire_on_commit=False:commit 后不失效对象属性,避免后续访问触发隐式
|
||||||
|
重新加载查询;需要最新值的场景由调用方显式 session.refresh()。
|
||||||
|
"""
|
||||||
|
with Session(assets_engine, expire_on_commit=False) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|
||||||
|
|
||||||
def get_metrics_session() -> Generator[Session, None, None]:
|
def get_metrics_session() -> Generator[Session, None, None]:
|
||||||
"""监控 / 日志库会话"""
|
"""监控 / 日志库会话"""
|
||||||
with Session(metrics_engine) as session:
|
with Session(metrics_engine, expire_on_commit=False) as session:
|
||||||
yield session
|
yield session
|
||||||
|
|||||||
+13
-5
@@ -35,12 +35,20 @@ def _asset_version() -> str:
|
|||||||
|
|
||||||
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
|
用于前端引用 ?v= 参数,绕开浏览器启发式缓存(旧响应无 Cache-Control
|
||||||
时存下的条目会被视为新鲜而不再回源验证)。
|
时存下的条目会被视为新鲜而不再回源验证)。
|
||||||
|
启动时计算一次并缓存:静态资源只在代码更新时变化,而更新后服务会重启,
|
||||||
|
避免每次页面请求都遍历 stat 整个 static 目录。
|
||||||
"""
|
"""
|
||||||
latest = 0
|
global _ASSET_VERSION_CACHE
|
||||||
for p in STATIC_DIR.rglob("*"):
|
if _ASSET_VERSION_CACHE is None:
|
||||||
if p.is_file():
|
latest = 0
|
||||||
latest = max(latest, int(p.stat().st_mtime))
|
for p in STATIC_DIR.rglob("*"):
|
||||||
return str(latest)
|
if p.is_file():
|
||||||
|
latest = max(latest, int(p.stat().st_mtime))
|
||||||
|
_ASSET_VERSION_CACHE = str(latest)
|
||||||
|
return _ASSET_VERSION_CACHE
|
||||||
|
|
||||||
|
|
||||||
|
_ASSET_VERSION_CACHE: str | None = None
|
||||||
|
|
||||||
# 全局日志配置:统一格式,便于生产环境排查
|
# 全局日志配置:统一格式,便于生产环境排查
|
||||||
logging.basicConfig(
|
logging.basicConfig(
|
||||||
|
|||||||
+2
-13
@@ -7,7 +7,7 @@ from sqlmodel import Session, select
|
|||||||
|
|
||||||
from app.core.security import require_api_key
|
from app.core.security import require_api_key
|
||||||
from app.database import get_metrics_session
|
from app.database import get_metrics_session
|
||||||
from app.models.monitor import MetricPoint, SecurityCheck, ServerInfo
|
from app.models.monitor import MetricPoint, ServerInfo
|
||||||
from app.services import cleanup_service, security_service
|
from app.services import cleanup_service, security_service
|
||||||
|
|
||||||
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
router = APIRouter(prefix="/api/monitor", tags=["monitor"])
|
||||||
@@ -55,18 +55,7 @@ def info(asset_id: int, session: Session = Depends(get_metrics_session)) -> Opti
|
|||||||
|
|
||||||
@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
|
@router.get("/{asset_id}/security", summary="安全检查项(每项最新一条)")
|
||||||
def security(asset_id: int, session: Session = Depends(get_metrics_session)):
|
def security(asset_id: int, session: Session = Depends(get_metrics_session)):
|
||||||
stmt = (
|
return security_service.latest_checks(session, asset_id)
|
||||||
select(SecurityCheck)
|
|
||||||
.where(SecurityCheck.asset_id == asset_id)
|
|
||||||
.order_by(SecurityCheck.ts.desc())
|
|
||||||
.limit(50)
|
|
||||||
)
|
|
||||||
checks = session.exec(stmt).all()
|
|
||||||
latest_by_item = {}
|
|
||||||
for check in checks:
|
|
||||||
if check.check_item not in latest_by_item:
|
|
||||||
latest_by_item[check.check_item] = check
|
|
||||||
return list(latest_by_item.values())
|
|
||||||
|
|
||||||
|
|
||||||
@router.get("/{asset_id}/security-score", summary="单个服务器安全评分与加固建议")
|
@router.get("/{asset_id}/security-score", summary="单个服务器安全评分与加固建议")
|
||||||
|
|||||||
@@ -283,14 +283,19 @@ def delete_asset(session: Session, asset_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _cleanup_metrics_for_asset(asset_id: int) -> None:
|
def _cleanup_metrics_for_asset(asset_id: int) -> None:
|
||||||
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog"""
|
"""清理 metrics.db 中该资产的 MetricPoint/ServerInfo/SecurityCheck/EventLog
|
||||||
|
|
||||||
|
使用批量 DELETE(而非逐行加载后删除):监控时序数据可能上万行,
|
||||||
|
逐行删除会全部载入内存且产生数万次 ORM 操作。
|
||||||
|
"""
|
||||||
|
from sqlmodel import delete
|
||||||
|
|
||||||
from app.database import metrics_engine
|
from app.database import metrics_engine
|
||||||
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
|
from app.models.monitor import EventLog, MetricPoint, SecurityCheck, ServerInfo
|
||||||
|
|
||||||
with Session(metrics_engine) as ms:
|
with Session(metrics_engine) as ms:
|
||||||
for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog):
|
for model in (MetricPoint, ServerInfo, SecurityCheck, EventLog):
|
||||||
for row in ms.exec(select(model).where(model.asset_id == asset_id)).all():
|
ms.exec(delete(model).where(model.asset_id == asset_id))
|
||||||
ms.delete(row)
|
|
||||||
ms.commit()
|
ms.commit()
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -15,19 +15,26 @@ DEFAULT_WEIGHT = 20
|
|||||||
|
|
||||||
|
|
||||||
def latest_checks(session: Session, asset_id: int) -> list:
|
def latest_checks(session: Session, asset_id: int) -> list:
|
||||||
"""取每个检查项的最新一条"""
|
"""取每个检查项的最新一条
|
||||||
stmt = (
|
|
||||||
select(SecurityCheck)
|
用 GROUP BY max(ts) 子查询精确取每项最新记录:若用 limit(50) 后去重,
|
||||||
|
当某个检查项连续上报超过 50 次时会把其他项的最新记录挤出,导致评分失真。
|
||||||
|
"""
|
||||||
|
latest_ts = (
|
||||||
|
select(
|
||||||
|
SecurityCheck.check_item,
|
||||||
|
func.max(SecurityCheck.ts).label("max_ts"),
|
||||||
|
)
|
||||||
.where(SecurityCheck.asset_id == asset_id)
|
.where(SecurityCheck.asset_id == asset_id)
|
||||||
.order_by(SecurityCheck.ts.desc())
|
.group_by(SecurityCheck.check_item)
|
||||||
.limit(50)
|
.subquery()
|
||||||
)
|
)
|
||||||
checks = session.exec(stmt).all()
|
stmt = select(SecurityCheck).where(SecurityCheck.asset_id == asset_id).join(
|
||||||
latest = {}
|
latest_ts,
|
||||||
for check in checks:
|
(SecurityCheck.check_item == latest_ts.c.check_item)
|
||||||
if check.check_item not in latest:
|
& (SecurityCheck.ts == latest_ts.c.max_ts),
|
||||||
latest[check.check_item] = check
|
)
|
||||||
return list(latest.values())
|
return list(session.exec(stmt).all())
|
||||||
|
|
||||||
|
|
||||||
def compute_security_score(checks: list):
|
def compute_security_score(checks: list):
|
||||||
|
|||||||
@@ -224,12 +224,36 @@ def delete_site_cert(session: Session, cert_id: int) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def check_all_site_certs(session: Session) -> dict:
|
def check_all_site_certs(session: Session) -> dict:
|
||||||
"""全量刷新所有站点证书,返回统计与异常清单"""
|
"""全量刷新所有站点证书,返回统计与异常清单
|
||||||
|
|
||||||
|
探测为纯网络 IO(单次最长 8s),用线程池并发探测后统一写库:
|
||||||
|
串行时 N 个站点最坏耗时 N×8s,并发后接近单站点耗时;
|
||||||
|
写库集中在主线程一次 commit(原来逐条 commit 产生 N 次事务)。
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
certs = session.exec(select(SiteCert)).all()
|
certs = session.exec(select(SiteCert)).all()
|
||||||
stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0}
|
stats = {"total": len(certs), "ok": 0, "error": 0, "expiring": 0, "expired": 0}
|
||||||
problems = []
|
problems = []
|
||||||
|
if not certs:
|
||||||
|
return {"stats": stats, "problems": problems}
|
||||||
|
|
||||||
|
# 并发探测(不碰数据库,线程安全)
|
||||||
|
with ThreadPoolExecutor(max_workers=min(8, len(certs))) as pool:
|
||||||
|
futures = {pool.submit(probe_site_cert, c.hostname, c.port): c for c in certs}
|
||||||
|
for future in futures:
|
||||||
|
cert = futures[future]
|
||||||
|
try:
|
||||||
|
_apply_probe(cert, future.result())
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
cert.status = "error"
|
||||||
|
cert.error = str(e)[:200]
|
||||||
|
cert.last_checked_at = utcnow()
|
||||||
|
session.add(cert)
|
||||||
|
session.commit()
|
||||||
|
|
||||||
for cert in certs:
|
for cert in certs:
|
||||||
check_one(session, cert)
|
session.refresh(cert)
|
||||||
if cert.status == "error":
|
if cert.status == "error":
|
||||||
stats["error"] += 1
|
stats["error"] += 1
|
||||||
problems.append({"hostname": cert.hostname, "detail": cert.error})
|
problems.append({"hostname": cert.hostname, "detail": cert.error})
|
||||||
|
|||||||
@@ -299,26 +299,44 @@ def refresh_ai_balance(session: Session, asset_id: int) -> dict:
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def sync_all_ai_balances(session: Session) -> dict:
|
def sync_all_ai_balances(session: Session, max_workers: int = 5) -> dict:
|
||||||
"""遍历所有 AI 账号资产,逐个刷新余额(单个失败不中断整体)"""
|
"""并发刷新所有 AI 账号余额(单个失败不中断整体)
|
||||||
|
|
||||||
|
每个账号需调用外部 API(单次最长 30s),串行时总耗时随账号数线性增长;
|
||||||
|
改为线程池并发后显著提速。注意:SQLite Session 不能跨线程共享,
|
||||||
|
每个 worker 使用独立 Session(WAL 模式下多连接读写安全)。
|
||||||
|
"""
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
|
||||||
|
from app.database import assets_engine
|
||||||
|
|
||||||
ai_assets = session.exec(
|
ai_assets = session.exec(
|
||||||
select(Asset).where(Asset.asset_type == AssetType.AI_AGENT)
|
select(Asset).where(Asset.asset_type == AssetType.AI_AGENT)
|
||||||
).all()
|
).all()
|
||||||
|
asset_ids = [(a.id, a.name) for a in ai_assets]
|
||||||
|
|
||||||
|
def _refresh_one(asset_id: int):
|
||||||
|
with Session(assets_engine) as worker_session:
|
||||||
|
refresh_ai_balance(worker_session, asset_id)
|
||||||
|
|
||||||
success = 0
|
success = 0
|
||||||
failed = 0
|
failed = 0
|
||||||
errors = []
|
errors = []
|
||||||
for asset in ai_assets:
|
with ThreadPoolExecutor(max_workers=max_workers) as pool:
|
||||||
try:
|
futures = {pool.submit(_refresh_one, aid): name for aid, name in asset_ids}
|
||||||
refresh_ai_balance(session, asset.id)
|
for future in futures:
|
||||||
success += 1
|
name = futures[future]
|
||||||
except HTTPException as e:
|
try:
|
||||||
failed += 1
|
future.result()
|
||||||
errors.append(f"{asset.name}: {e.detail}")
|
success += 1
|
||||||
except Exception as e: # noqa: BLE001
|
except HTTPException as e:
|
||||||
failed += 1
|
failed += 1
|
||||||
errors.append(f"{asset.name}: {e}")
|
errors.append(f"{name}: {e.detail}")
|
||||||
|
except Exception as e: # noqa: BLE001
|
||||||
|
failed += 1
|
||||||
|
errors.append(f"{name}: {e}")
|
||||||
return {
|
return {
|
||||||
"total": len(ai_assets),
|
"total": len(asset_ids),
|
||||||
"success": success,
|
"success": success,
|
||||||
"failed": failed,
|
"failed": failed,
|
||||||
"errors": errors,
|
"errors": errors,
|
||||||
|
|||||||
Reference in New Issue
Block a user