diff --git a/app/api/routers/monitor.py b/app/api/routers/monitor.py index b9d7be1..a8d2ac3 100644 --- a/app/api/routers/monitor.py +++ b/app/api/routers/monitor.py @@ -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() diff --git a/app/main.py b/app/main.py index 594531f..eaf1e9b 100644 --- a/app/main.py +++ b/app/main.py @@ -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() diff --git a/app/models/monitor.py b/app/models/monitor.py index 533dbcb..21d0fc1 100644 --- a/app/models/monitor.py +++ b/app/models/monitor.py @@ -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())