From 55bfeca88e6c7858b5cd197a7163ce790b322cb0 Mon Sep 17 00:00:00 2001 From: root Date: Sun, 3 May 2026 21:37:29 +0000 Subject: [PATCH] feat(V2): IBKR key rotation admin endpoints + health probe Co-Authored-By: Claude Opus 4.7 (1M context) --- src/cerbero_mcp/admin.py | 85 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) diff --git a/src/cerbero_mcp/admin.py b/src/cerbero_mcp/admin.py index b3f445f..cc01125 100644 --- a/src/cerbero_mcp/admin.py +++ b/src/cerbero_mcp/admin.py @@ -8,6 +8,9 @@ from pathlib import Path from typing import Any, Literal from fastapi import APIRouter, HTTPException, Query, Request +from pydantic import BaseModel, SecretStr + +from cerbero_mcp.exchanges.ibkr.key_rotation import KeyRotationManager MAX_RECORDS = 10000 DEFAULT_LIMIT = 1000 @@ -155,4 +158,86 @@ def make_admin_router() -> APIRouter: }, } + class _IBKRRotateConfirmReq(BaseModel): + new_consumer_key: str + new_access_token: str + new_access_token_secret: str + + @r.post("/ibkr/rotate-keys/start") + async def _ibkr_rotate_start(env: str, request: Request): + if env not in ("testnet", "mainnet"): + raise HTTPException(400, detail={"error": "invalid env"}) + settings = request.app.state.settings + creds = settings.ibkr.credentials(env) + mgr = KeyRotationManager( + signature_key_path=creds["signature_key_path"], + encryption_key_path=creds["encryption_key_path"], + ) + rotations = getattr(request.app.state, "ibkr_rotations", None) + if rotations is None: + rotations = {} + request.app.state.ibkr_rotations = rotations + rotations[env] = mgr + return await mgr.start() + + @r.post("/ibkr/rotate-keys/confirm") + async def _ibkr_rotate_confirm( + env: str, body: _IBKRRotateConfirmReq, request: Request, + ): + if env not in ("testnet", "mainnet"): + raise HTTPException(400, detail={"error": "invalid env"}) + rotations = getattr(request.app.state, "ibkr_rotations", {}) or {} + mgr = rotations.get(env) + if mgr is None: + raise HTTPException(409, detail={"error": "rotation not started"}) + + settings = request.app.state.settings + if env == "testnet": + settings.ibkr.consumer_key_testnet = body.new_consumer_key + settings.ibkr.access_token_testnet = body.new_access_token + settings.ibkr.access_token_secret_testnet = SecretStr(body.new_access_token_secret) + else: + settings.ibkr.consumer_key_live = body.new_consumer_key + settings.ibkr.access_token_live = body.new_access_token + settings.ibkr.access_token_secret_live = SecretStr(body.new_access_token_secret) + + registry = request.app.state.registry + registry._clients.pop(("ibkr", env), None) + + async def _validate() -> bool: + try: + client = await registry.get("ibkr", env) + await client._request("GET", "/iserver/auth/status", skip_tickle=True) + return True + except Exception: + return False + + try: + return await mgr.confirm(validate=_validate) + finally: + rotations.pop(env, None) + + @r.post("/ibkr/rotate-keys/abort") + async def _ibkr_rotate_abort(env: str, request: Request): + rotations = getattr(request.app.state, "ibkr_rotations", {}) or {} + mgr = rotations.pop(env, None) + if mgr is None: + return {"aborted": False, "reason": "no rotation in progress"} + return await mgr.abort() + + @r.post("/ibkr/health") + async def _ibkr_health(request: Request): + registry = request.app.state.registry + out: dict[str, Any] = {} + for env in ("testnet", "mainnet"): + try: + client = await registry.get("ibkr", env) + status = await client._request( + "GET", "/iserver/auth/status", skip_tickle=True + ) + out[env] = {"healthy": True, "status": status} + except Exception as e: + out[env] = {"healthy": False, "error": str(e)[:200]} + return out + return r