"""Callback management API endpoints.""" import asyncio import logging import subprocess import sys import time from concurrent.futures import ThreadPoolExecutor from typing import Any from fastapi import APIRouter, Depends, HTTPException, Request, status from pydantic import BaseModel, Field from ..auth import verify_token from ..config import CallbackConfig, settings from ..config_manager import config_manager from ..services.rate_limit import check as ratelimit_check from ..services.rate_limit import get_peer router = APIRouter(prefix="/api/callbacks", tags=["callbacks"]) logger = logging.getLogger(__name__) # Dedicated executor for callback/subprocess execution _callback_executor = ThreadPoolExecutor(max_workers=4, thread_name_prefix="callback") def shutdown_callback_executor() -> None: """Shut down the callback executor cleanly on application teardown.""" _callback_executor.shutdown(wait=False, cancel_futures=True) def _require_callbacks_management() -> None: """Authorise a callbacks-CRUD operation. Operator flag + per-token admin scope.""" if not settings.callbacks_management: raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=( "Callbacks management is disabled. Set callbacks_management: true" " in config.yaml to enable." ), ) from ..auth import auth_enabled, token_has_scope, token_label_var if auth_enabled(): label = token_label_var.get("unknown") if not token_has_scope(label, "admin"): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"Token '{label}' lacks required scope: admin", ) class CallbackInfo(BaseModel): """Information about a configured callback.""" name: str command: str timeout: int working_dir: str | None = None shell: bool class CallbackCreateRequest(BaseModel): """Request model for creating or updating a callback.""" command: str = Field(..., description="Command to execute", min_length=1) timeout: int = Field(default=30, description="Execution timeout in seconds", ge=1, le=300) working_dir: str | None = Field(default=None, description="Working directory") shell: bool = Field(default=True, description="Run command in shell") class CallbackExecuteResponse(BaseModel): """Response model for callback execution.""" success: bool callback: str exit_code: int | None = None stdout: str = "" stderr: str = "" error: str | None = None execution_time: float | None = None def _validate_callback_name(name: str) -> None: """Validate callback name. Args: name: Callback name to validate. Raises: HTTPException: If name is invalid. """ # All available callback events valid_names = { "on_play", "on_pause", "on_stop", "on_next", "on_previous", "on_volume", "on_mute", "on_seek", "on_turn_on", "on_turn_off", "on_toggle", } if name not in valid_names: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=f"Callback name must be one of: {', '.join(sorted(valid_names))}", ) @router.get("/list") async def list_callbacks(_: str = Depends(verify_token)) -> list[CallbackInfo]: """List all configured callbacks. Returns: List of configured callbacks. """ return [ CallbackInfo( name=name, command=config.command, timeout=config.timeout, working_dir=config.working_dir, shell=config.shell, ) for name, config in settings.callbacks.items() ] @router.post("/execute/{callback_name}") async def execute_callback( callback_name: str, http_request: Request, _: str = Depends(verify_token), ) -> CallbackExecuteResponse: """Execute a callback for debugging purposes. Args: callback_name: Name of the callback to execute Returns: Execution result including stdout, stderr, and exit code """ # Rate-limit callback execution per peer (10/min) — callbacks also run # subprocesses and need the same protection as scripts. allowed, retry_after = ratelimit_check("execute", get_peer(http_request)) if not allowed: raise HTTPException( status_code=429, detail="Too many callback executions, slow down", headers={"Retry-After": str(int(retry_after or 60))}, ) # Validate callback name _validate_callback_name(callback_name) # Check if callback exists if callback_name not in settings.callbacks: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Callback '{callback_name}' not found. Use /api/callbacks/list to see configured callbacks.", ) callback_config = settings.callbacks[callback_name] logger.info(f"Executing callback for debugging: {callback_name}") from ..services.audit_log import record_script_execution try: # Execute in dedicated thread pool to not block the default executor loop = asyncio.get_running_loop() result = await loop.run_in_executor( _callback_executor, lambda: _run_callback( command=callback_config.command, timeout=callback_config.timeout, shell=callback_config.shell, working_dir=callback_config.working_dir, ), ) record_script_execution( kind="callback", name=callback_name, exit_code=result["exit_code"], duration=result.get("execution_time"), stdout=result.get("stdout"), stderr=result.get("stderr"), ) return CallbackExecuteResponse( success=result["exit_code"] == 0, callback=callback_name, exit_code=result["exit_code"], stdout=result["stdout"], stderr=result["stderr"], execution_time=result.get("execution_time"), ) except Exception as e: logger.error(f"Callback execution error: {e}") record_script_execution( kind="callback", name=callback_name, exit_code=None, duration=None, error=str(e), ) return CallbackExecuteResponse( success=False, callback=callback_name, error=str(e), ) def _run_callback( command: str, timeout: int, shell: bool, working_dir: str | None, ) -> dict[str, Any]: """Run a callback synchronously. Args: command: Command to execute timeout: Timeout in seconds shell: Whether to run in shell working_dir: Working directory Returns: Dict with exit_code, stdout, stderr, execution_time """ start_time = time.time() popen_kwargs: dict[str, Any] = {} if sys.platform == "win32": popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP else: popen_kwargs["start_new_session"] = True try: result = subprocess.run( command, shell=shell, cwd=working_dir, capture_output=True, text=True, timeout=timeout, **popen_kwargs, ) execution_time = time.time() - start_time return { "exit_code": result.returncode, "stdout": result.stdout[:10000], # Limit output size "stderr": result.stderr[:10000], "execution_time": execution_time, } except subprocess.TimeoutExpired: execution_time = time.time() - start_time return { "exit_code": -1, "stdout": "", "stderr": f"Callback timed out after {timeout} seconds", "execution_time": execution_time, } except Exception as e: execution_time = time.time() - start_time return { "exit_code": -1, "stdout": "", "stderr": str(e), "execution_time": execution_time, } @router.post("/create/{callback_name}") async def create_callback( callback_name: str, request: CallbackCreateRequest, _: str = Depends(verify_token), ) -> dict[str, Any]: """Create a new callback. Args: callback_name: Callback event name (on_turn_on, on_turn_off, on_toggle). request: Callback configuration. Returns: Success response with callback name. Raises: HTTPException: If callback already exists or name is invalid. """ _require_callbacks_management() _validate_callback_name(callback_name) # Check if callback already exists if callback_name in settings.callbacks: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail=( f"Callback '{callback_name}' already exists." f" Use PUT /api/callbacks/update/{callback_name} to update it." ), ) # Create callback config callback_config = CallbackConfig(**request.model_dump()) # Add to config file and in-memory try: config_manager.add_callback(callback_name, callback_config) except Exception as e: logger.error(f"Failed to add callback '{callback_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to add callback: {str(e)}", ) logger.info(f"Callback '{callback_name}' created successfully") return {"success": True, "callback": callback_name} @router.put("/update/{callback_name}") async def update_callback( callback_name: str, request: CallbackCreateRequest, _: str = Depends(verify_token), ) -> dict[str, Any]: """Update an existing callback. Args: callback_name: Callback event name. request: Updated callback configuration. Returns: Success response with callback name. Raises: HTTPException: If callback does not exist. """ _require_callbacks_management() _validate_callback_name(callback_name) # Check if callback exists if callback_name not in settings.callbacks: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=( f"Callback '{callback_name}' not found." f" Use POST /api/callbacks/create/{callback_name} to create it." ), ) # Create updated callback config callback_config = CallbackConfig(**request.model_dump()) # Update config file and in-memory try: config_manager.update_callback(callback_name, callback_config) except Exception as e: logger.error(f"Failed to update callback '{callback_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to update callback: {str(e)}", ) logger.info(f"Callback '{callback_name}' updated successfully") return {"success": True, "callback": callback_name} @router.delete("/delete/{callback_name}") async def delete_callback( callback_name: str, _: str = Depends(verify_token), ) -> dict[str, Any]: """Delete a callback. Args: callback_name: Callback event name. Returns: Success response with callback name. Raises: HTTPException: If callback does not exist. """ _require_callbacks_management() _validate_callback_name(callback_name) # Check if callback exists if callback_name not in settings.callbacks: raise HTTPException( status_code=status.HTTP_404_NOT_FOUND, detail=f"Callback '{callback_name}' not found", ) # Delete from config file and in-memory try: config_manager.delete_callback(callback_name) except Exception as e: logger.error(f"Failed to delete callback '{callback_name}': {e}") raise HTTPException( status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail=f"Failed to delete callback: {str(e)}", ) logger.info(f"Callback '{callback_name}' deleted successfully") return {"success": True, "callback": callback_name}