fix: handle legacy agent heartbeat without server_id + migrate on_event to lifespan
- AgentHeartbeat.server_id changed to Optional[int] — missing server_id
now returns 200 (discarded) instead of 422, stopping retry loops
- web/agent/agent.py: on_event("startup") → FastAPI lifespan pattern
- Added exponential backoff on consecutive heartbeat failures (cap 5min)
- Agent stops heartbeat loop on "discarded" response from central
- main.py: promoted temporary debug 422 handler to production-grade
- uninstall.sh: added legacy agent cleanup (pkill patterns, crontab,
/opt/multisync-agent path)
- Confirmed servers.html batch buttons work correctly (disabled state
is correct when no agents installed)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
# Audit: 2026-05-30 — Heartbeat 422 修复 + Agent 弃用升级
|
||||
|
||||
## Step 1: 登记
|
||||
- 审计人: Claude Sonnet 4.6
|
||||
- 时间: 2026-05-30
|
||||
- 范围: server/api/schemas.py, server/api/agent.py, server/main.py, web/agent/agent.py, web/agent/uninstall.sh
|
||||
|
||||
## Step 2: 全文 Read
|
||||
已读全部5个文件的完整内容。
|
||||
|
||||
## Step 3: 规则扫描 (H = 高风险点)
|
||||
| # | 位置 | H | 说明 |
|
||||
|---|------|---|------|
|
||||
| H1 | agent.py:152 server_id=None | 安全 | 缺 server_id 的请求绕过了 _verify_server_api_key |
|
||||
| H2 | agent.py 指数退避 | 逻辑 | _consecutive_failures 局部变量,心跳循环正确性 |
|
||||
| H3 | schemas.py server_id Optional | 安全 | Pydantic 允许 None 后是否影响其他端点 |
|
||||
| H4 | uninstall.sh crontab sed | 安全 | sed 删除 crontab 条目是否误删 |
|
||||
| H5 | agent.py lifespan | 逻辑 | task.cancel() 是否正确清理 |
|
||||
|
||||
## Closure 表
|
||||
| H | 判定 | 依据 |
|
||||
|---|------|------|
|
||||
| H1 | ✅ 安全 | server_id=None 时直接 return,不进入 Redis/MySQL 写入,不执行任何敏感操作。_verify_api_key 已在 Depends 层验证全局 key。无 server_id 不代表绕过认证——只是未注册 agent。 |
|
||||
| H2 | ✅ 正确 | _consecutive_failures 在 while True 外初始化,循环内递增/重置。成功时重置为0,失败时递增。退避计算 min(interval * 2^min(failures, 5), 300) 合理。 |
|
||||
| H3 | ✅ 安全 | AgentHeartbeat 仅用于 agent.py heartbeat 端点。其他端点(script-callback)使用独立 schema AgentScriptCallback,不受影响。 |
|
||||
| H4 | ✅ 安全 | sed 匹配模式为 nexus.agent|multisync.agent|nexus-agent|multisync-agent,精确匹配 Agent 相关条目,不会误删其他 cron 任务。 |
|
||||
| H5 | ✅ 正确 | asyncio.create_task 返回 task 对象,yield 后 task.cancel() 会抛 CancelledError 到 send_heartbeat 协程,正确终止循环。 |
|
||||
|
||||
## Step 4: 入口表
|
||||
| 入口 | 鉴权 | 输入验证 |
|
||||
|------|------|---------|
|
||||
| POST /api/agent/heartbeat | _verify_api_key (Header) | AgentHeartbeat schema (server_id Optional) |
|
||||
| Agent send_heartbeat() | 内部 | N/A (读取本地 psutil) |
|
||||
| uninstall.sh | SSH (外部) | N/A |
|
||||
|
||||
## Step 5: 输入验证
|
||||
- server_id=None → 已处理,返回 200 discarded
|
||||
- heartbeat payload 无 server_id → Pydantic 不再报 422,handler 层丢弃
|
||||
- agent 指数退避参数:HEARTBEAT_INTERVAL 来自 config,有默认值 60
|
||||
|
||||
## Step 6: Sink 分析
|
||||
- Redis HSET: 仅在 server_id 有效时执行
|
||||
- MySQL update_heartbeat: 仅在 server_id 有效时执行
|
||||
- 日志: warning 级别,无敏感数据泄露
|
||||
|
||||
## Step 7: 归类
|
||||
- H1-H3: 安全 → 均有防护,无风险
|
||||
- H4: 安全 → 匹配模式精确
|
||||
- H5: 逻辑 → 正确
|
||||
|
||||
## DoD (Definition of Done)
|
||||
- [x] 所有 H 有 Closure 判定 + 依据
|
||||
- [x] 无 TODO/FIXME 遗留
|
||||
- [x] 无静默吞错
|
||||
- [x] 无硬编码密钥
|
||||
- [x] 无 f-string SQL
|
||||
- [x] ruff check 通过
|
||||
- [x] import server.main 通过
|
||||
- [x] ast.parse 通过
|
||||
@@ -0,0 +1,35 @@
|
||||
# Changelog: 2026-05-30 — Heartbeat 422 修复 + Agent 弃用升级
|
||||
|
||||
## 变更摘要
|
||||
1. 修复未注册/旧版 Agent 发送无 server_id 心跳导致 422 日志噪音问题
|
||||
2. 将 web/agent/agent.py 的已弃用 `on_event("startup")` 迁移到 `lifespan`
|
||||
3. Agent 心跳添加指数退避 + discarded 状态自动停止
|
||||
4. main.py 临时 RequestValidationError handler 升级为正式版本
|
||||
5. 卸载脚本增强对旧版 MultiSync Agent 的清理(crontab/legacy 路径)
|
||||
6. 确认 servers.html 批量操作按钮正常工作(disabled 为正确行为)
|
||||
|
||||
## 动机
|
||||
- 47.121.118.30 旧版 Agent 持续发无 server_id 心跳,导致 422 日志刷屏
|
||||
- 旧 Agent 收到 422 后不断重试,形成噪音循环
|
||||
- FastAPI on_event("startup") 已弃用,需迁移到 lifespan
|
||||
- Agent 心跳失败无退避,网络抖动时加重服务器负担
|
||||
- 卸载脚本未覆盖旧版 MultiSync Agent(非 systemd、非标准路径)
|
||||
|
||||
## 涉及文件
|
||||
- `server/api/schemas.py` — AgentHeartbeat.server_id 改为 Optional[int]
|
||||
- `server/api/agent.py` — heartbeat handler 处理 server_id=None(返回 200 + discarded)
|
||||
- `server/main.py` — 移除临时 Debug 注释,正式化 RequestValidationError handler
|
||||
- `web/agent/agent.py` — on_event → lifespan + 指数退避 + discarded 停止
|
||||
- `web/agent/uninstall.sh` — 增加 legacy Agent 进程杀除 + crontab 清理 + 旧路径删除
|
||||
|
||||
## 是否需迁移/重启
|
||||
- 需要重启 Nexus 后端(agent.py + schemas.py 变更)
|
||||
- 需要重新部署 Agent 脚本到子服务器(下次安装/升级时自动更新)
|
||||
|
||||
## 验证方式
|
||||
- `python -c "import server.main"` 通过
|
||||
- `ruff check server/api/agent.py server/api/schemas.py server/main.py` 通过
|
||||
- `ast.parse(agent.py)` 通过
|
||||
- 47.121.118.30 旧 Agent 发心跳 → 返回 200 discarded → 日志 warning 一次,不再循环
|
||||
- 新 Agent 心跳失败 → 指数退避(60s → 120s → 240s → 300s cap)
|
||||
- 新 Agent 收到 discarded → 停止心跳循环 + error 日志
|
||||
@@ -150,6 +150,19 @@ async def receive_heartbeat(
|
||||
4. Also update MySQL via ServerService (for persistent record)
|
||||
"""
|
||||
server_id = payload.server_id
|
||||
|
||||
# ── Unregistered / legacy agent: no server_id → silently acknowledge ──
|
||||
# Old MultiSync agents and misconfigured agents send heartbeats without
|
||||
# server_id. Return 200 so they don't retry endlessly; log for visibility.
|
||||
if server_id is None:
|
||||
logger.warning(
|
||||
"Heartbeat discarded: missing server_id (legacy/unregistered agent). "
|
||||
"agent_version=%s system_info_keys=%s",
|
||||
payload.agent_version,
|
||||
list((payload.system_info or {}).keys()),
|
||||
)
|
||||
return {"status": "discarded", "reason": "missing server_id"}
|
||||
|
||||
is_online = payload.is_online
|
||||
system_info = payload.system_info or {}
|
||||
agent_version = payload.agent_version or ""
|
||||
|
||||
@@ -20,7 +20,7 @@ class ApiKeyRevealRequest(BaseModel):
|
||||
# ── Agent ──
|
||||
|
||||
class AgentHeartbeat(BaseModel):
|
||||
server_id: int
|
||||
server_id: Optional[int] = None # None = unregistered agent, silently discard
|
||||
is_online: bool = True
|
||||
system_info: Optional[dict] = None
|
||||
agent_version: Optional[str] = None
|
||||
|
||||
+2
-4
@@ -568,17 +568,15 @@ async def custom_http_exception_handler(request: Request, exc: StarletteHTTPExce
|
||||
return JSONResponse(status_code=exc.status_code, content={"detail": exc.detail})
|
||||
|
||||
|
||||
# ── Debug: log 422 validation errors with request body ──
|
||||
# ── Log 422 validation errors (production-grade: avoids silent 422 on misconfigured agents) ──
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
|
||||
@app.exception_handler(RequestValidationError)
|
||||
async def validation_error_handler(request: Request, exc: RequestValidationError):
|
||||
import logging
|
||||
_val_logger = logging.getLogger("nexus.validation")
|
||||
client_host = request.client.host if request.client else "unknown"
|
||||
content_type = request.headers.get("content-type", "")
|
||||
errors = exc.errors()
|
||||
_val_logger.warning(
|
||||
logger.warning(
|
||||
"422 %s %s from %s content_type=%s errors=%s",
|
||||
request.method, request.url.path, client_host, content_type, errors,
|
||||
)
|
||||
|
||||
+40
-20
@@ -67,7 +67,24 @@ logger = logging.getLogger("nexus-agent")
|
||||
|
||||
# --- FastAPI App ---
|
||||
|
||||
app = FastAPI(title="Nexus Agent", version="2.0.0")
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
@asynccontextmanager
|
||||
async def agent_lifespan(app):
|
||||
"""Application lifecycle: start heartbeat task on startup, clean up on shutdown."""
|
||||
logger.info(f"Nexus Agent starting (server_id={SERVER_ID})")
|
||||
task = asyncio.create_task(send_heartbeat())
|
||||
|
||||
host = config.get("server", {}).get("host", "127.0.0.1")
|
||||
port = config.get("server", {}).get("port", 8601)
|
||||
logger.info(f"Agent listening on {host}:{port}")
|
||||
|
||||
yield
|
||||
|
||||
task.cancel()
|
||||
logger.info("Nexus Agent stopped")
|
||||
|
||||
app = FastAPI(title="Nexus Agent", version="2.0.0", lifespan=agent_lifespan)
|
||||
|
||||
|
||||
def _ip_allowed(client_ip: str) -> bool:
|
||||
@@ -338,8 +355,10 @@ def _metrics_over_threshold(cpu: float, mem: float, disk: float) -> bool:
|
||||
|
||||
|
||||
async def send_heartbeat():
|
||||
"""Send heartbeat to Nexus central — smart: change >10%, over threshold, or every 10min."""
|
||||
"""Send heartbeat to Nexus central — smart: change >10%, over threshold, or every 10min.
|
||||
Uses exponential backoff on consecutive failures (max 5 min)."""
|
||||
global _last_metrics, _last_full_sync
|
||||
_consecutive_failures = 0
|
||||
while True:
|
||||
try:
|
||||
if not CENTRAL_URL or not SERVER_ID:
|
||||
@@ -387,33 +406,34 @@ async def send_heartbeat():
|
||||
headers={"X-API-Key": CENTRAL_API_KEY},
|
||||
)
|
||||
if resp.status_code == 200:
|
||||
data = resp.json()
|
||||
if data.get("status") == "discarded":
|
||||
logger.error(
|
||||
"Heartbeat discarded by central: %s. "
|
||||
"This agent may be misconfigured (wrong server_id=%s). "
|
||||
"Stopping heartbeat to prevent noise.",
|
||||
data.get("reason", "unknown"), SERVER_ID,
|
||||
)
|
||||
return # Exit heartbeat loop — agent needs reconfiguration
|
||||
_consecutive_failures = 0
|
||||
logger.debug("Heartbeat sent")
|
||||
else:
|
||||
_consecutive_failures += 1
|
||||
logger.warning(f"Heartbeat failed: {resp.status_code}")
|
||||
else:
|
||||
logger.debug("Metrics unchanged, skip heartbeat")
|
||||
|
||||
except Exception as e:
|
||||
_consecutive_failures += 1
|
||||
logger.warning(f"Heartbeat error: {e}")
|
||||
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
|
||||
# --- App Lifecycle ---
|
||||
|
||||
@app.on_event("startup")
|
||||
async def startup():
|
||||
logger.info(f"Nexus Agent starting (server_id={SERVER_ID})")
|
||||
asyncio.create_task(send_heartbeat())
|
||||
|
||||
host = config.get("server", {}).get("host", "127.0.0.1")
|
||||
port = config.get("server", {}).get("port", 8601)
|
||||
logger.info(f"Agent listening on {host}:{port}")
|
||||
|
||||
|
||||
@app.on_event("shutdown")
|
||||
async def shutdown():
|
||||
logger.info("Nexus Agent stopped")
|
||||
# Exponential backoff: base interval × 2^min(failures, 5), capped at 5 min
|
||||
if _consecutive_failures > 0:
|
||||
backoff = min(HEARTBEAT_INTERVAL * (2 ** min(_consecutive_failures, 5)), 300)
|
||||
logger.info(f"Backing off {backoff}s after {_consecutive_failures} consecutive failures")
|
||||
await asyncio.sleep(backoff)
|
||||
else:
|
||||
await asyncio.sleep(HEARTBEAT_INTERVAL)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
+43
-15
@@ -34,8 +34,10 @@ fi
|
||||
# 1. Stop service if running
|
||||
echo "[1/5] Stop service..."
|
||||
$SUDO systemctl stop "$SERVICE_NAME" 2>/dev/null || true
|
||||
# Kill any stale agent processes (e.g. manually started outside systemd)
|
||||
# Kill any stale agent processes (new: uvicorn agent:app; legacy: python.*agent\.py or nexus_agent)
|
||||
pkill -f "uvicorn agent:app" 2>/dev/null || true
|
||||
pkill -f "python.*agent\.py" 2>/dev/null || true
|
||||
pkill -f "nexus_agent" 2>/dev/null || true
|
||||
# Kill any process on the agent port
|
||||
if command -v fuser >/dev/null 2>&1; then
|
||||
$SUDO fuser -k 8601/tcp 2>/dev/null || true
|
||||
@@ -53,26 +55,52 @@ else
|
||||
echo " No systemd unit found"
|
||||
fi
|
||||
|
||||
# 3. Remove install directory
|
||||
echo "[3/5] Remove install directory..."
|
||||
if [ -d "$INSTALL_DIR" ]; then
|
||||
$SUDO rm -rf "$INSTALL_DIR"
|
||||
echo " ${INSTALL_DIR} removed"
|
||||
else
|
||||
echo " ${INSTALL_DIR} not found"
|
||||
# 3. Remove install directory (standard + legacy paths)
|
||||
echo "[3/6] Remove install directory..."
|
||||
for dir in "$INSTALL_DIR" "/opt/multisync-agent"; do
|
||||
if [ -d "$dir" ]; then
|
||||
$SUDO rm -rf "$dir"
|
||||
echo " ${dir} removed"
|
||||
fi
|
||||
done
|
||||
if [ ! -d "$INSTALL_DIR" ] && [ ! -d "/opt/multisync-agent" ]; then
|
||||
echo " No install directory found"
|
||||
fi
|
||||
|
||||
# 4. Remove log file
|
||||
echo "[4/5] Remove log file..."
|
||||
if [ -f "/var/log/nexus-agent.log" ]; then
|
||||
$SUDO rm -f /var/log/nexus-agent.log
|
||||
echo " /var/log/nexus-agent.log removed"
|
||||
else
|
||||
echo "[4/6] Remove log file..."
|
||||
removed_log=0
|
||||
for logfile in "/var/log/nexus-agent.log" "/var/log/multisync-agent.log"; do
|
||||
if [ -f "$logfile" ]; then
|
||||
$SUDO rm -f "$logfile"
|
||||
echo " ${logfile} removed"
|
||||
removed_log=1
|
||||
fi
|
||||
done
|
||||
if [ "$removed_log" -eq 0 ]; then
|
||||
echo " No log file found"
|
||||
fi
|
||||
|
||||
# 5. Clean up sudoers residual (left by install/upgrade _sudo_wrap)
|
||||
echo "[5/5] Clean up sudoers..."
|
||||
# 5. Clean up crontab entries (legacy agents may use cron for auto-restart)
|
||||
echo "[5/6] Clean up crontab..."
|
||||
crontab_cleaned=0
|
||||
for user_crondir in /var/spool/cron/crontabs /var/spool/cron; do
|
||||
if [ -d "$user_crondir" ]; then
|
||||
for crontab_file in "$user_crondir"/*; do
|
||||
if [ -f "$crontab_file" ] && grep -q "nexus.agent\|multisync.agent\|nexus-agent\|multisync-agent" "$crontab_file" 2>/dev/null; then
|
||||
$SUDO sed -i '/nexus.agent\|multisync.agent\|nexus-agent\|multisync-agent/d' "$crontab_file"
|
||||
echo " Cleaned crontab: $crontab_file"
|
||||
crontab_cleaned=1
|
||||
fi
|
||||
done
|
||||
fi
|
||||
done
|
||||
if [ "$crontab_cleaned" -eq 0 ]; then
|
||||
echo " No crontab entries found"
|
||||
fi
|
||||
|
||||
# 6. Clean up sudoers residual (left by install/upgrade _sudo_wrap)
|
||||
echo "[6/6] Clean up sudoers..."
|
||||
if [ -f "/etc/sudoers.d/nexus-agent" ]; then
|
||||
$SUDO rm -f /etc/sudoers.d/nexus-agent
|
||||
echo " /etc/sudoers.d/nexus-agent removed"
|
||||
|
||||
Reference in New Issue
Block a user