feat: time drift detection between agent and central server

Agent (web/agent/agent.py):
- Heartbeat now includes system_info.agent_time (UTC ISO string)

Central (server/api/agent.py):
- On each heartbeat, compare agent_time vs central datetime.now(utc)
- Thresholds: warn >= 30s, crit >= 60s (affects TOTP/cron accuracy)
- Stores time_drift_seconds + drift_level in Redis heartbeat key
- Sends Telegram alert (5-min cooldown) when drift >= 60s critical

API (server/api/servers.py):
- List and detail endpoints expose time_drift_seconds + drift_level from Redis

Frontend (web/app/servers.html):
- driftBadge(): shows clock emoji badge next to server name
  - amber badge: >=30s (warn)
  - red badge: >=60s (crit)
  - tooltip shows exact drift and NTP check hint
  - no badge when drift < 30s or no data

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-05-23 17:04:34 +08:00
parent 5a42345bc0
commit 9c063a788f
4 changed files with 74 additions and 1 deletions
+49
View File
@@ -159,6 +159,38 @@ async def receive_heartbeat(
raise HTTPException(status_code=401, detail="Invalid API key for this server")
# ── 1. Write to Redis (real-time data) ──
# Time drift detection: compare agent_time with central server time
TIME_DRIFT_WARN_SECONDS = 30 # ≥30s: warning (yellow)
TIME_DRIFT_CRIT_SECONDS = 60 # ≥60s: critical (red, affects TOTP / cron)
time_drift_seconds: float = 0.0
drift_level: str = "ok" # ok / warn / crit
agent_time_str = system_info.get("agent_time", "") if system_info else ""
if agent_time_str:
try:
agent_ts = datetime.fromisoformat(agent_time_str)
central_ts = datetime.now(timezone.utc)
# Normalise to aware for comparison
if agent_ts.tzinfo is None:
agent_ts = agent_ts.replace(tzinfo=timezone.utc)
drift = abs((central_ts - agent_ts).total_seconds())
time_drift_seconds = round(drift, 1)
if drift >= TIME_DRIFT_CRIT_SECONDS:
drift_level = "crit"
logger.warning(
"Clock drift CRITICAL: server %s drift=%.1fs (agent_time=%s)",
server_id, drift, agent_time_str,
)
elif drift >= TIME_DRIFT_WARN_SECONDS:
drift_level = "warn"
logger.warning(
"Clock drift WARNING: server %s drift=%.1fs",
server_id, drift,
)
except Exception as e:
logger.debug(f"Failed to parse agent_time for drift check: {e}")
redis = get_redis()
try:
redis_key = f"{REDIS_KEY_PREFIX}{server_id}"
@@ -167,6 +199,8 @@ async def receive_heartbeat(
"system_info": json.dumps(system_info),
"last_heartbeat": datetime.now(timezone.utc).isoformat(),
"agent_version": agent_version,
"time_drift_seconds": str(time_drift_seconds),
"drift_level": drift_level,
})
# Set TTL = flush interval × 1.5 (prevent stale online status after key expires)
await redis.expire(redis_key, REDIS_KEY_EXPIRE)
@@ -183,6 +217,21 @@ async def receive_heartbeat(
except Exception:
logger.debug(f"Failed to look up server name for server {server_id}")
# ── 1c. Time drift Telegram alert (critical only, 5-min cooldown like metric alerts) ──
if drift_level == "crit":
from server.api.websocket import _should_push_telegram
drift_alert_key = f"alert:{server_id}:time_drift"
if _should_push_telegram(drift_alert_key):
try:
from server.infrastructure.telegram import send_telegram_system_alert
await send_telegram_system_alert(
f"🕐 时钟偏差过大 — 服务器 #{server_id} ({server_name})\n"
f"偏差 {time_drift_seconds}s(≥60s 影响 TOTP / cron 精度)\n"
f"请检查 NTP 配置"
)
except Exception:
logger.debug("Failed to send drift alert via Telegram")
# ── 2. Alert detection ──
alert_thresholds = {
"cpu": settings.CPU_ALERT_THRESHOLD,
+11
View File
@@ -70,6 +70,12 @@ async def list_servers(
pass # Keep MySQL value
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
# Time drift detection fields
try:
server_data["time_drift_seconds"] = float(heartbeat.get("time_drift_seconds", 0))
except (ValueError, TypeError):
server_data["time_drift_seconds"] = 0.0
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
server_data["_source"] = "redis"
except Exception as e:
logger.warning(f"Redis read failed for server {server.id}: {e}")
@@ -173,6 +179,11 @@ async def get_server(
pass
server_data["last_heartbeat"] = heartbeat.get("last_heartbeat", server_data.get("last_heartbeat"))
server_data["agent_version"] = heartbeat.get("agent_version", server_data.get("agent_version", ""))
try:
server_data["time_drift_seconds"] = float(heartbeat.get("time_drift_seconds", 0))
except (ValueError, TypeError):
server_data["time_drift_seconds"] = 0.0
server_data["drift_level"] = heartbeat.get("drift_level", "ok")
server_data["_source"] = "redis"
except Exception as e:
logger.warning(f"Redis read failed for server {id}: {e}")
+2
View File
@@ -316,6 +316,8 @@ async def send_heartbeat():
"disk_usage": disk,
"hostname": platform.node(),
"platform": platform.platform(),
# agent_time: central uses this to detect clock drift
"agent_time": datetime.now(timezone.utc).isoformat(),
},
}
+12 -1
View File
@@ -156,7 +156,7 @@
tbody.innerHTML=servers.map(s=>`<tr class="hover:bg-slate-800/30 transition cursor-pointer ${selectedServerId===s.id?'bg-brand/5':''}" onclick="selectServer(${s.id})" id="row-${s.id}">
<td class="px-4 py-3" onclick="event.stopPropagation()"><input type="checkbox" class="rounded border-slate-600 bg-slate-700 text-brand focus:ring-brand srv-chk" data-id="${s.id}" onchange="toggleSelect(${s.id},this.checked)" ${_selectedIds.has(s.id)?'checked':''}></td>
<td class="px-4 py-3"><span class="inline-block w-2.5 h-2.5 rounded-full ${s.is_online?'bg-green-400':'bg-red-400'} ${s.is_online?'':'animate-pulse'}"></span></td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}</td>
<td class="px-4 py-3 text-white font-medium">${esc(s.name)}${driftBadge(s)}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.domain)}:${s.port||22}</td>
<td class="px-4 py-3 text-slate-400">${esc(s.category||'--')}</td>
<td class="px-4 py-3 text-slate-400">${s.agent_version||'--'}</td>
@@ -281,6 +281,17 @@
hideServerModal();loadServers();
if(selectedServerId&&id==selectedServerId)selectServer(parseInt(id));
}
function driftBadge(s){
const level=s.drift_level;
const drift=s.time_drift_seconds||0;
if(!level||level==='ok'||!drift)return'';
const isCrit=level==='crit';
const color=isCrit?'bg-red-500/20 text-red-400 border-red-500/40':'bg-amber-500/20 text-amber-400 border-amber-500/40';
const icon='🕐';
const tip=`时钟偏差 ${drift}s${isCrit?' — 影响 TOTP/cron,请检查 NTP':' — 建议检查 NTP'}`;
return ` <span class="ml-1.5 inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded border ${color} text-[10px] font-medium align-middle cursor-default" title="${tip}">${icon} ${drift}s</span>`;
}
function esc(s){if(!s)return'';const d=document.createElement('div');d.textContent=s;return d.innerHTML}
// ── Generate Agent API Key ──