🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
465 lines
14 KiB
Python
465 lines
14 KiB
Python
"""Proxmark3 API endpoints.
|
|
|
|
Refactored to use PM3Service for all business logic.
|
|
Endpoints are now thin adapters that convert HTTP requests/responses.
|
|
|
|
Multi-device support:
|
|
- GET /devices - List all devices
|
|
- GET /devices/available - List available devices
|
|
- POST /devices/{device_id}/identify - Blink device LEDs
|
|
- GET /devices/{device_id}/status - Get device-specific status
|
|
- GET /status?device_id=xxx - Get status (all devices or specific)
|
|
- POST /command - Execute command (with optional device_id)
|
|
"""
|
|
from fastapi import APIRouter, HTTPException, Query
|
|
from pydantic import BaseModel, Field
|
|
from typing import Optional, List, Dict, Any
|
|
|
|
from ..services.container import container
|
|
|
|
router = APIRouter()
|
|
|
|
|
|
class CommandRequest(BaseModel):
|
|
command: str
|
|
session_id: Optional[str] = None
|
|
device_id: Optional[str] = None # Multi-device support
|
|
|
|
|
|
class CommandResponse(BaseModel):
|
|
success: bool
|
|
output: str
|
|
error: Optional[str] = None
|
|
|
|
|
|
class StatusResponse(BaseModel):
|
|
connected: bool
|
|
device: str
|
|
version: Optional[str] = None
|
|
session_active: bool
|
|
|
|
|
|
class FirmwareInfo(BaseModel):
|
|
"""Firmware information for a device."""
|
|
bootrom_version: Optional[str] = None
|
|
os_version: Optional[str] = None
|
|
compatible: bool = False
|
|
|
|
|
|
class DeviceInfo(BaseModel):
|
|
"""Device information response."""
|
|
device_id: str
|
|
device_path: str
|
|
friendly_name: Optional[str] = None
|
|
serial_number: Optional[str] = None
|
|
status: str
|
|
connected: bool
|
|
in_use: bool
|
|
firmware_info: FirmwareInfo
|
|
usb_vid: Optional[str] = None
|
|
usb_pid: Optional[str] = None
|
|
last_seen: str
|
|
|
|
|
|
class DeviceListResponse(BaseModel):
|
|
"""Response for device list endpoints."""
|
|
devices: List[DeviceInfo]
|
|
|
|
|
|
class DeviceStatusResponse(BaseModel):
|
|
"""Response for single device status."""
|
|
device_id: str
|
|
device_path: str
|
|
friendly_name: Optional[str] = None
|
|
serial_number: Optional[str] = None
|
|
connected: bool
|
|
in_use: bool
|
|
status: str
|
|
firmware_info: FirmwareInfo
|
|
last_seen: str
|
|
|
|
|
|
class IdentifyRequest(BaseModel):
|
|
"""Request to identify a device (blink LEDs)."""
|
|
duration_ms: int = Field(default=2000, ge=500, le=10000, description="LED blink duration in milliseconds")
|
|
|
|
|
|
class FlashRequest(BaseModel):
|
|
"""Request to flash firmware to a device."""
|
|
confirm: bool = Field(..., description="User confirmation that they want to flash")
|
|
|
|
|
|
class FlashResponse(BaseModel):
|
|
"""Response from flash operation."""
|
|
success: bool
|
|
message: str
|
|
device_id: str
|
|
|
|
|
|
def _service_error_to_http_status(error_code: str) -> int:
|
|
"""Map service error codes to HTTP status codes.
|
|
|
|
Args:
|
|
error_code: Service error code
|
|
|
|
Returns:
|
|
HTTP status code
|
|
"""
|
|
codes = {
|
|
# Session errors
|
|
"session_locked": 423,
|
|
"session_not_found": 404,
|
|
# Connection errors
|
|
"pm3_not_connected": 503,
|
|
"connection_error": 503,
|
|
"disconnection_error": 500,
|
|
# Command execution errors
|
|
"command_failed": 500,
|
|
"execution_error": 500,
|
|
"status_error": 500,
|
|
# Multi-device errors
|
|
"device_not_found": 404,
|
|
"device_manager_not_available": 503,
|
|
"list_devices_error": 500,
|
|
"get_available_devices_error": 500,
|
|
"identify_device_error": 500,
|
|
# Firmware flash errors
|
|
"device_busy": 409,
|
|
"flash_failed": 500,
|
|
"flash_error": 500,
|
|
}
|
|
return codes.get(error_code, 500)
|
|
|
|
|
|
@router.get("/status")
|
|
async def get_status(device_id: Optional[str] = Query(None, description="Optional device ID for multi-device support")):
|
|
"""Get Proxmark3 status.
|
|
|
|
Multi-device support:
|
|
- If device_id is None and device_manager exists: returns all devices
|
|
- If device_id is provided: returns specific device status
|
|
- If device_id is None and no device_manager: returns legacy single device status
|
|
|
|
Args:
|
|
device_id: Optional device ID for multi-device mode
|
|
|
|
Returns:
|
|
StatusResponse (legacy single device) or dict with devices list (multi-device)
|
|
"""
|
|
result = await container.pm3_service.get_status(device_id=device_id)
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message
|
|
)
|
|
|
|
# Multi-device mode: return all devices
|
|
if "devices" in result.data:
|
|
return {"devices": result.data["devices"]}
|
|
|
|
# Single device mode (specific device or legacy)
|
|
return StatusResponse(
|
|
connected=result.data["connected"],
|
|
device=result.data["device_path"],
|
|
version=result.data.get("version"),
|
|
session_active=result.data["session_active"]
|
|
)
|
|
|
|
|
|
@router.post("/connect")
|
|
async def connect():
|
|
"""Connect to Proxmark3 device.
|
|
|
|
Uses PM3Service for business logic.
|
|
"""
|
|
result = await container.pm3_service.connect()
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message
|
|
)
|
|
|
|
return {"success": True, "message": result.data["message"]}
|
|
|
|
|
|
@router.post("/disconnect")
|
|
async def disconnect():
|
|
"""Disconnect from Proxmark3 device.
|
|
|
|
Uses PM3Service for business logic.
|
|
"""
|
|
result = await container.pm3_service.disconnect()
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message
|
|
)
|
|
|
|
return {"success": True, "message": result.data["message"]}
|
|
|
|
|
|
@router.post("/command", response_model=CommandResponse)
|
|
async def execute_command(request: CommandRequest):
|
|
"""Execute a Proxmark3 command.
|
|
|
|
Uses PM3Service for all business logic including:
|
|
- Session validation
|
|
- Command execution
|
|
- Session activity updates
|
|
- Multi-device support (via device_id in request)
|
|
|
|
Multi-device support:
|
|
- If device_id is provided: command executes on specified device
|
|
- If device_id is None: uses legacy single-device mode
|
|
|
|
Args:
|
|
request: CommandRequest with command, optional session_id, and optional device_id
|
|
"""
|
|
result = await container.pm3_service.execute_command(
|
|
command=request.command,
|
|
session_id=request.session_id,
|
|
device_id=request.device_id
|
|
)
|
|
|
|
if result.success:
|
|
return CommandResponse(
|
|
success=True,
|
|
output=result.data["output"],
|
|
error=None
|
|
)
|
|
else:
|
|
# For session_locked, return 423 via HTTPException
|
|
# For other errors, return in response body
|
|
if result.error.code == "session_locked":
|
|
raise HTTPException(
|
|
status_code=423,
|
|
detail=result.error.message
|
|
)
|
|
|
|
# Include details in error message for better diagnostics
|
|
error_msg = result.error.message
|
|
if result.error.details:
|
|
error_msg = f"{error_msg}: {result.error.details}"
|
|
|
|
return CommandResponse(
|
|
success=False,
|
|
output="",
|
|
error=error_msg
|
|
)
|
|
|
|
|
|
@router.get("/commands/history")
|
|
async def get_command_history(limit: int = 50):
|
|
"""Get recent command history from database."""
|
|
import aiosqlite
|
|
from .. import config
|
|
|
|
try:
|
|
async with aiosqlite.connect(config.DATABASE_PATH) as db:
|
|
db.row_factory = aiosqlite.Row
|
|
cursor = await db.execute(
|
|
"SELECT command, response, success, executed_at FROM command_history ORDER BY executed_at DESC LIMIT ?",
|
|
(limit,)
|
|
)
|
|
rows = await cursor.fetchall()
|
|
return {"history": [dict(row) for row in rows]}
|
|
except Exception as e:
|
|
return {"history": [], "error": str(e)}
|
|
|
|
|
|
# ============================================================================
|
|
# Multi-Device Endpoints
|
|
# ============================================================================
|
|
|
|
|
|
@router.get("/devices", response_model=DeviceListResponse)
|
|
async def list_devices():
|
|
"""List all discovered PM3 devices.
|
|
|
|
Returns all devices regardless of their status (connected, in use, etc.).
|
|
Uses PM3DeviceManager via PM3Service.
|
|
|
|
Returns:
|
|
DeviceListResponse: List of all devices with their status and firmware info
|
|
"""
|
|
result = await container.pm3_service.list_devices()
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message
|
|
)
|
|
|
|
# Convert device dictionaries to DeviceInfo models
|
|
devices = []
|
|
for device_dict in result.data["devices"]:
|
|
devices.append(DeviceInfo(
|
|
device_id=device_dict["device_id"],
|
|
device_path=device_dict["device_path"],
|
|
friendly_name=device_dict.get("friendly_name"),
|
|
serial_number=device_dict.get("serial_number"),
|
|
status=device_dict["status"],
|
|
connected=device_dict["status"] in ["CONNECTED", "IN_USE"],
|
|
in_use=device_dict["status"] == "IN_USE",
|
|
firmware_info=FirmwareInfo(
|
|
bootrom_version=device_dict["firmware_info"].get("bootrom_version"),
|
|
os_version=device_dict["firmware_info"].get("os_version"),
|
|
compatible=device_dict["firmware_info"].get("compatible", False)
|
|
),
|
|
usb_vid=device_dict.get("usb_vid"),
|
|
usb_pid=device_dict.get("usb_pid"),
|
|
last_seen=device_dict["last_seen"]
|
|
))
|
|
|
|
return DeviceListResponse(devices=devices)
|
|
|
|
|
|
@router.get("/devices/available", response_model=DeviceListResponse)
|
|
async def list_available_devices():
|
|
"""List available PM3 devices (not currently in use).
|
|
|
|
Returns only devices that are connected but do not have active sessions.
|
|
These devices can be selected for new operations.
|
|
|
|
Returns:
|
|
DeviceListResponse: List of available devices
|
|
"""
|
|
result = await container.pm3_service.get_available_devices()
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message
|
|
)
|
|
|
|
# Convert device dictionaries to DeviceInfo models
|
|
devices = []
|
|
for device_dict in result.data["devices"]:
|
|
devices.append(DeviceInfo(
|
|
device_id=device_dict["device_id"],
|
|
device_path=device_dict["device_path"],
|
|
friendly_name=device_dict.get("friendly_name"),
|
|
serial_number=device_dict.get("serial_number"),
|
|
status=device_dict["status"],
|
|
connected=device_dict["status"] in ["CONNECTED", "IN_USE"],
|
|
in_use=device_dict["status"] == "IN_USE",
|
|
firmware_info=FirmwareInfo(
|
|
bootrom_version=device_dict["firmware_info"].get("bootrom_version"),
|
|
os_version=device_dict["firmware_info"].get("os_version"),
|
|
compatible=device_dict["firmware_info"].get("compatible", False)
|
|
),
|
|
usb_vid=device_dict.get("usb_vid"),
|
|
usb_pid=device_dict.get("usb_pid"),
|
|
last_seen=device_dict["last_seen"]
|
|
))
|
|
|
|
return DeviceListResponse(devices=devices)
|
|
|
|
|
|
@router.post("/devices/{device_id}/identify")
|
|
async def identify_device(device_id: str, request: IdentifyRequest = IdentifyRequest()):
|
|
"""Identify a PM3 device by blinking its LEDs.
|
|
|
|
Useful for physically identifying which device corresponds to a device_id
|
|
when multiple devices are connected.
|
|
|
|
Args:
|
|
device_id: Device ID to identify
|
|
request: Request with optional duration_ms (default: 2000ms)
|
|
|
|
Returns:
|
|
Success message
|
|
"""
|
|
result = await container.pm3_service.identify_device(
|
|
device_id=device_id,
|
|
duration_ms=request.duration_ms
|
|
)
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message
|
|
)
|
|
|
|
return {"success": True, "message": result.data["message"]}
|
|
|
|
|
|
@router.get("/devices/{device_id}/status", response_model=DeviceStatusResponse)
|
|
async def get_device_status(device_id: str):
|
|
"""Get status for a specific PM3 device.
|
|
|
|
Args:
|
|
device_id: Device ID to query
|
|
|
|
Returns:
|
|
DeviceStatusResponse: Device status and firmware info
|
|
"""
|
|
result = await container.pm3_service.get_status(device_id=device_id)
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message
|
|
)
|
|
|
|
data = result.data
|
|
return DeviceStatusResponse(
|
|
device_id=data["device_id"],
|
|
device_path=data["device_path"],
|
|
friendly_name=data.get("friendly_name"),
|
|
serial_number=data.get("serial_number"),
|
|
connected=data["connected"],
|
|
in_use=data["in_use"],
|
|
status=data["status"],
|
|
firmware_info=FirmwareInfo(
|
|
bootrom_version=data["firmware_info"]["bootrom_version"],
|
|
os_version=data["firmware_info"]["os_version"],
|
|
compatible=data["firmware_info"]["compatible"]
|
|
),
|
|
last_seen=data["last_seen"]
|
|
)
|
|
|
|
|
|
@router.post("/devices/{device_id}/flash", response_model=FlashResponse)
|
|
async def flash_device(device_id: str, request: FlashRequest):
|
|
"""Flash firmware to a PM3 device.
|
|
|
|
Flashes both bootrom and fullimage from bundled firmware files.
|
|
Progress is reported via WebSocket notifications (pm3_flash_progress event).
|
|
|
|
Args:
|
|
device_id: Device ID to flash
|
|
request: FlashRequest with confirmation
|
|
|
|
Returns:
|
|
FlashResponse with success status and message
|
|
|
|
Raises:
|
|
HTTPException 400: If confirmation not provided
|
|
HTTPException 404: If device not found
|
|
HTTPException 409: If device is already being flashed
|
|
HTTPException 500: If flash operation fails
|
|
"""
|
|
if not request.confirm:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Confirmation required. Set confirm=true to proceed with flash."
|
|
)
|
|
|
|
result = await container.pm3_service.flash_firmware(device_id=device_id)
|
|
|
|
if not result.success:
|
|
raise HTTPException(
|
|
status_code=_service_error_to_http_status(result.error.code),
|
|
detail=result.error.message if not result.error.details else f"{result.error.message}: {result.error.details}"
|
|
)
|
|
|
|
return FlashResponse(
|
|
success=True,
|
|
message=result.data["message"],
|
|
device_id=device_id
|
|
)
|