feat: monitor API key flow and versioned telemetry #10

Merged
hzhang merged 5 commits from feat/monitor-api-key-v2 into main 2026-03-20 09:18:09 +00:00
3 changed files with 72 additions and 1 deletions
Showing only changes of commit c70f90cb52 - Show all commits

View File

@@ -1,9 +1,10 @@
from datetime import datetime, timedelta, timezone
import json
import secrets
import uuid
from typing import List, Dict
from fastapi import APIRouter, Depends, HTTPException, status, WebSocket, WebSocketDisconnect
from fastapi import APIRouter, Depends, Header, HTTPException, status, WebSocket, WebSocketDisconnect
from pydantic import BaseModel
from sqlalchemy.orm import Session
@@ -171,6 +172,29 @@ def delete_server(server_id: int, db: Session = Depends(get_db), _: models.User
return None
@router.post('/admin/servers/{server_id}/api-key')
def generate_api_key(server_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
"""Generate or regenerate API Key for a server (heartbeat v2)"""
server = db.query(MonitoredServer).filter(MonitoredServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail='Server not found')
api_key = secrets.token_urlsafe(32)
server.api_key = api_key
db.commit()
return {'server_id': server.id, 'api_key': api_key, 'message': 'Store this key securely - it will not be shown again'}
@router.delete('/admin/servers/{server_id}/api-key', status_code=status.HTTP_204_NO_CONTENT)
def revoke_api_key(server_id: int, db: Session = Depends(get_db), _: models.User = Depends(require_admin)):
"""Revoke API Key for a server"""
server = db.query(MonitoredServer).filter(MonitoredServer.id == server_id).first()
if not server:
raise HTTPException(status_code=404, detail='Server not found')
server.api_key = None
db.commit()
return None
class ServerHeartbeat(BaseModel):
identifier: str
openclaw_version: str | None = None
@@ -201,6 +225,47 @@ def server_heartbeat(payload: ServerHeartbeat, db: Session = Depends(get_db)):
return {'ok': True, 'server_id': server.id, 'last_seen_at': st.last_seen_at}
# Heartbeat v2 with API Key authentication
class TelemetryPayload(BaseModel):
identifier: str
openclaw_version: str | None = None
agents: List[dict] = []
cpu_pct: float | None = None
mem_pct: float | None = None
disk_pct: float | None = None
swap_pct: float | None = None
load_avg: list[float] | None = None
uptime_seconds: int | None = None
@router.post('/server/heartbeat-v2')
def server_heartbeat_v2(
payload: TelemetryPayload,
x_api_key: str = Header(..., alias='X-API-Key', description='API Key from /admin/servers/{id}/api-key'),
db: Session = Depends(get_db)
):
"""Server heartbeat using API Key authentication (no challenge_uuid required)"""
server = db.query(MonitoredServer).filter(
MonitoredServer.api_key == x_api_key,
MonitoredServer.is_enabled == True
).first()
if not server:
raise HTTPException(status_code=401, detail='Invalid or missing API Key')
st = db.query(ServerState).filter(ServerState.server_id == server.id).first()
if not st:
st = ServerState(server_id=server.id)
db.add(st)
st.openclaw_version = payload.openclaw_version
st.agents_json = json.dumps(payload.agents, ensure_ascii=False)
st.cpu_pct = payload.cpu_pct
st.mem_pct = payload.mem_pct
st.disk_pct = payload.disk_pct
st.swap_pct = payload.swap_pct
st.last_seen_at = datetime.now(timezone.utc)
db.commit()
return {'ok': True, 'server_id': server.id, 'identifier': server.identifier, 'last_seen_at': st.last_seen_at}
@router.websocket('/server/ws')
async def server_ws(websocket: WebSocket):
await websocket.accept()

View File

@@ -215,6 +215,11 @@ def _migrate_schema():
"DEFAULT 'open'"
))
# --- monitored_servers.api_key for heartbeat v2 ---
if _has_table(db, "monitored_servers") and not _has_column(db, "monitored_servers", "api_key"):
db.execute(text("ALTER TABLE monitored_servers ADD COLUMN api_key VARCHAR(64) NULL"))
db.execute(text("CREATE UNIQUE INDEX idx_monitored_servers_api_key ON monitored_servers (api_key)"))
db.commit()
except Exception as e:
db.rollback()

View File

@@ -39,6 +39,7 @@ class MonitoredServer(Base):
identifier = Column(String(128), nullable=False, unique=True)
display_name = Column(String(128), nullable=True)
is_enabled = Column(Boolean, default=True)
api_key = Column(String(64), nullable=True, unique=True, index=True) # API Key for server heartbeat v2
created_by = Column(Integer, nullable=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())