fix(security): BL-06 强制 Agent per-server key 并合并心跳单次查库

移除 Agent 认证与安装链路的 global API_KEY 回退,收口 risk-acceptance 接受项 2;巡检补修心跳重复 get_server 并加 assert_called_once 回归测试。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Nexus Agent
2026-06-07 11:17:41 +08:00
parent e5e2582743
commit eab35a35be
11 changed files with 308 additions and 105 deletions
+26 -39
View File
@@ -21,6 +21,7 @@ from server.api.dependencies import get_db, get_server_service
from server.api.schemas import AgentHeartbeat, AgentScriptCallback
from server.api.websocket import broadcast_alert, broadcast_recovery
from server.application.services.server_service import ServerService
from server.domain.models import Server
from server.config import settings
from server.infrastructure.redis.client import get_redis
from server.infrastructure.telegram import send_telegram_system_alert
@@ -38,32 +39,24 @@ REDIS_ALERT_KEY_PREFIX = "alerts:"
def _verify_api_key(x_api_key: str = Header(...)):
"""Verify Agent API key — accepts either global or per-server key.
For heartbeat endpoints, the global key is checked first; if it doesn't
match, the request is still allowed through so the handler can verify
the per-server key (which requires server_id from the payload).
"""Extract Agent API key header. Actual auth is per-server in the handler.
server_id lives in the payload (not the header), so per-server verification
runs inside the route via _verify_server_api_key(). Here we only ensure the
header is present; an empty key is rejected early.
"""
if not x_api_key:
raise HTTPException(status_code=401, detail="Missing API key")
# If the global key matches, allow immediately
if secrets.compare_digest(x_api_key, settings.API_KEY):
return x_api_key
# Non-matching key — allow through for per-server verification in handler.
# The handler MUST call _verify_server_api_key() before processing.
# This two-step approach is needed because server_id is in the payload,
# not the header, so per-server verification can't happen in a Depends().
return x_api_key
async def _verify_server_api_key(server_id: int, provided_key: str, service: ServerService) -> bool:
"""Verify API key against per-server agent_api_key, with limited global fallback.
async def _verify_server_api_key(
server_id: int, provided_key: str, service: ServerService
) -> Optional[Server]:
"""Load server and verify per-server agent_api_key (single DB read).
Authentication priority:
1. Server must exist
2. If server has agent_api_key → must match exactly
3. Legacy only: no per-server key → global API_KEY (logged for migration)
Returns the Server on success; None if missing, misconfigured, wrong key, or DB error.
No global API_KEY fallback (BL-06: per-server enforced).
"""
try:
server = await service.get_server(server_id)
@@ -73,24 +66,25 @@ async def _verify_server_api_key(server_id: int, provided_key: str, service: Ser
server_id,
exc_info=True,
)
return False
return None
if not server:
return False
return None
if server.agent_api_key:
return secrets.compare_digest(provided_key, server.agent_api_key)
if secrets.compare_digest(provided_key, settings.API_KEY):
if not server.agent_api_key:
logger.warning(
"Server %s (%s) used global API_KEY for agent auth — "
"assign per-server key via POST /api/servers/%s/agent-key",
"Server %s (%s) rejected: no agent_api_key set (global fallback removed). "
"Generate via POST /api/servers/%s/agent-key",
server_id,
server.name,
server_id,
)
return True
return False
return None
if not secrets.compare_digest(provided_key, server.agent_api_key):
return None
return server
def _threshold_for(thresholds: dict, metric: str) -> float:
@@ -186,17 +180,10 @@ async def receive_heartbeat(
system_info = payload.system_info or {}
agent_version = payload.agent_version or ""
# ── Per-server API key verification ──
if not await _verify_server_api_key(server_id, api_key, service):
raise HTTPException(status_code=401, detail="Invalid API key for this server")
server = await service.get_server(server_id)
# ── Per-server API key verification (single get_server) ──
server = await _verify_server_api_key(server_id, api_key, service)
if not server:
logger.warning(
"Heartbeat discarded: unknown server_id=%s (unregistered host)",
server_id,
)
return {"status": "discarded", "reason": "unknown_server"}
raise HTTPException(status_code=401, detail="Invalid API key for this server")
# ── 1. Write to Redis (real-time data) ──
@@ -319,7 +306,7 @@ async def script_job_callback(
):
"""Receive long-task completion from child host (curl after nohup script exits).
Authenticated by X-API-Key (per-server or global) + one-time job_id + secret.
Authenticated by X-API-Key (per-server agent_api_key) + one-time job_id + secret.
"""
if not await _verify_server_api_key(payload.server_id, api_key, service):
raise HTTPException(status_code=401, detail="Invalid API key for this server")