Some checks are pending
Build Ghidra Plugin / build (push) Waiting to run
- Add ghydramcp Python package with FastMCP server implementation - Add docker-compose.yml for easy container management - Add Makefile with build/run targets - Add QUICKSTART.md for getting started - Add uv.lock for reproducible dependencies
657 lines
22 KiB
Python
657 lines
22 KiB
Python
"""Docker management mixin for GhydraMCP.
|
|
|
|
Provides tools for managing Ghidra Docker containers programmatically.
|
|
Allows the MCP server to automatically start containers when Ghidra isn't available.
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import shutil
|
|
import subprocess
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
|
|
from fastmcp import Context
|
|
from fastmcp.contrib.mcp_mixin import MCPMixin, mcp_tool
|
|
|
|
from ..config import get_config, get_docker_config
|
|
|
|
|
|
class DockerMixin(MCPMixin):
|
|
"""Docker container management for GhydraMCP.
|
|
|
|
Provides tools to start, stop, and manage Ghidra containers
|
|
with the GhydraMCP plugin pre-installed.
|
|
"""
|
|
|
|
# Track running containers
|
|
_containers: Dict[str, Dict[str, Any]] = {}
|
|
|
|
def __init__(self):
|
|
"""Initialize Docker mixin."""
|
|
self._check_docker_available()
|
|
|
|
def _check_docker_available(self) -> bool:
|
|
"""Check if Docker is available on the system."""
|
|
return shutil.which("docker") is not None
|
|
|
|
def _run_docker_cmd(
|
|
self, args: List[str], check: bool = True, capture: bool = True
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a docker command.
|
|
|
|
Args:
|
|
args: Command arguments (after 'docker')
|
|
check: Raise exception on non-zero exit
|
|
capture: Capture stdout/stderr
|
|
|
|
Returns:
|
|
CompletedProcess result
|
|
"""
|
|
cmd = ["docker"] + args
|
|
return subprocess.run(
|
|
cmd,
|
|
check=check,
|
|
capture_output=capture,
|
|
text=True,
|
|
)
|
|
|
|
def _run_compose_cmd(
|
|
self,
|
|
args: List[str],
|
|
project_dir: Optional[Path] = None,
|
|
check: bool = True,
|
|
capture: bool = True,
|
|
) -> subprocess.CompletedProcess:
|
|
"""Run a docker compose command.
|
|
|
|
Args:
|
|
args: Command arguments (after 'docker compose')
|
|
project_dir: Directory containing docker-compose.yml
|
|
check: Raise exception on non-zero exit
|
|
capture: Capture stdout/stderr
|
|
|
|
Returns:
|
|
CompletedProcess result
|
|
"""
|
|
cmd = ["docker", "compose"]
|
|
|
|
# Use project directory if specified
|
|
if project_dir:
|
|
cmd.extend(["-f", str(project_dir / "docker-compose.yml")])
|
|
|
|
cmd.extend(args)
|
|
|
|
env = os.environ.copy()
|
|
if project_dir:
|
|
env["COMPOSE_PROJECT_NAME"] = "ghydramcp"
|
|
|
|
return subprocess.run(
|
|
cmd,
|
|
check=check,
|
|
capture_output=capture,
|
|
text=True,
|
|
cwd=project_dir,
|
|
env=env,
|
|
)
|
|
|
|
@mcp_tool(
|
|
name="docker_status",
|
|
description="Check Docker availability and running GhydraMCP containers",
|
|
)
|
|
async def docker_status(self, ctx: Optional[Context] = None) -> Dict[str, Any]:
|
|
"""Check Docker status and list running GhydraMCP containers.
|
|
|
|
Returns:
|
|
Status information including:
|
|
- docker_available: Whether Docker is installed
|
|
- docker_running: Whether Docker daemon is running
|
|
- containers: List of GhydraMCP containers with their status
|
|
- images: Available GhydraMCP images
|
|
"""
|
|
result = {
|
|
"docker_available": False,
|
|
"docker_running": False,
|
|
"containers": [],
|
|
"images": [],
|
|
"compose_available": False,
|
|
}
|
|
|
|
# Check if docker is installed
|
|
if not self._check_docker_available():
|
|
return result
|
|
|
|
result["docker_available"] = True
|
|
|
|
# Check if docker daemon is running
|
|
try:
|
|
self._run_docker_cmd(["info"], check=True)
|
|
result["docker_running"] = True
|
|
except (subprocess.CalledProcessError, FileNotFoundError):
|
|
return result
|
|
|
|
# Check for docker compose
|
|
try:
|
|
self._run_docker_cmd(["compose", "version"], check=True)
|
|
result["compose_available"] = True
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
|
|
# List GhydraMCP containers
|
|
try:
|
|
ps_result = self._run_docker_cmd(
|
|
[
|
|
"ps",
|
|
"-a",
|
|
"--filter",
|
|
"label=org.opencontainers.image.title=ghydramcp",
|
|
"--format",
|
|
"{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}",
|
|
]
|
|
)
|
|
for line in ps_result.stdout.strip().split("\n"):
|
|
if line:
|
|
parts = line.split("\t")
|
|
if len(parts) >= 3:
|
|
result["containers"].append(
|
|
{
|
|
"id": parts[0],
|
|
"name": parts[1],
|
|
"status": parts[2],
|
|
"ports": parts[3] if len(parts) > 3 else "",
|
|
}
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
|
|
# Also check by name pattern
|
|
try:
|
|
ps_result = self._run_docker_cmd(
|
|
[
|
|
"ps",
|
|
"-a",
|
|
"--filter",
|
|
"name=ghydramcp",
|
|
"--format",
|
|
"{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.Ports}}",
|
|
]
|
|
)
|
|
existing_ids = {c["id"] for c in result["containers"]}
|
|
for line in ps_result.stdout.strip().split("\n"):
|
|
if line:
|
|
parts = line.split("\t")
|
|
if len(parts) >= 3 and parts[0] not in existing_ids:
|
|
result["containers"].append(
|
|
{
|
|
"id": parts[0],
|
|
"name": parts[1],
|
|
"status": parts[2],
|
|
"ports": parts[3] if len(parts) > 3 else "",
|
|
}
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
|
|
# List GhydraMCP images
|
|
try:
|
|
images_result = self._run_docker_cmd(
|
|
[
|
|
"images",
|
|
"--filter",
|
|
"reference=ghydramcp*",
|
|
"--format",
|
|
"{{.Repository}}:{{.Tag}}\t{{.Size}}\t{{.CreatedSince}}",
|
|
]
|
|
)
|
|
for line in images_result.stdout.strip().split("\n"):
|
|
if line:
|
|
parts = line.split("\t")
|
|
if len(parts) >= 2:
|
|
result["images"].append(
|
|
{
|
|
"name": parts[0],
|
|
"size": parts[1],
|
|
"created": parts[2] if len(parts) > 2 else "",
|
|
}
|
|
)
|
|
except subprocess.CalledProcessError:
|
|
pass
|
|
|
|
return result
|
|
|
|
@mcp_tool(
|
|
name="docker_start",
|
|
description="Start a GhydraMCP Docker container to analyze a binary",
|
|
)
|
|
async def docker_start(
|
|
self,
|
|
binary_path: str,
|
|
port: int = 8192,
|
|
memory: str = "2G",
|
|
name: Optional[str] = None,
|
|
ctx: Optional[Context] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Start a GhydraMCP Docker container for binary analysis.
|
|
|
|
This creates a new Ghidra instance in Docker with the GhydraMCP
|
|
plugin pre-installed. The binary will be imported and analyzed,
|
|
then the HTTP API will be available on the specified port.
|
|
|
|
Args:
|
|
binary_path: Path to the binary file to analyze
|
|
port: Port to expose the HTTP API (default: 8192)
|
|
memory: Max JVM heap memory (default: 2G)
|
|
name: Container name (auto-generated if not specified)
|
|
|
|
Returns:
|
|
Container info including ID, name, and API URL
|
|
"""
|
|
if not self._check_docker_available():
|
|
return {"error": "Docker is not available on this system"}
|
|
|
|
# Verify binary exists
|
|
binary_file = Path(binary_path).resolve()
|
|
if not binary_file.exists():
|
|
return {"error": f"Binary not found: {binary_path}"}
|
|
|
|
# Generate container name if not specified
|
|
if name is None:
|
|
name = f"ghydramcp-{binary_file.stem}-{port}"
|
|
|
|
# Clean up invalid characters in container name
|
|
name = "".join(c if c.isalnum() or c in "-_" else "-" for c in name)
|
|
|
|
try:
|
|
# Check if container with this name already exists
|
|
check_result = self._run_docker_cmd(
|
|
["ps", "-a", "-q", "-f", f"name={name}"], check=False
|
|
)
|
|
if check_result.stdout.strip():
|
|
return {
|
|
"error": f"Container '{name}' already exists. Stop it first with docker_stop."
|
|
}
|
|
|
|
# Check if port is already in use
|
|
port_check = self._run_docker_cmd(
|
|
["ps", "-q", "-f", f"publish={port}"], check=False
|
|
)
|
|
if port_check.stdout.strip():
|
|
return {
|
|
"error": f"Port {port} is already in use by another container"
|
|
}
|
|
|
|
# Start the container
|
|
run_result = self._run_docker_cmd(
|
|
[
|
|
"run",
|
|
"-d",
|
|
"--name",
|
|
name,
|
|
"-p",
|
|
f"{port}:8192",
|
|
"-v",
|
|
f"{binary_file.parent}:/binaries:ro",
|
|
"-e",
|
|
f"GHYDRA_MAXMEM={memory}",
|
|
"ghydramcp:latest",
|
|
f"/binaries/{binary_file.name}",
|
|
]
|
|
)
|
|
|
|
container_id = run_result.stdout.strip()
|
|
|
|
# Track the container
|
|
self._containers[container_id] = {
|
|
"name": name,
|
|
"port": port,
|
|
"binary": str(binary_file),
|
|
"memory": memory,
|
|
}
|
|
|
|
return {
|
|
"success": True,
|
|
"container_id": container_id[:12],
|
|
"name": name,
|
|
"port": port,
|
|
"api_url": f"http://localhost:{port}/",
|
|
"binary": str(binary_file),
|
|
"message": (
|
|
f"Container started. Analysis in progress. "
|
|
f"API will be available at http://localhost:{port}/ once analysis completes. "
|
|
f"Use docker_logs('{name}') to monitor progress."
|
|
),
|
|
}
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
return {"error": f"Failed to start container: {e.stderr or e.stdout}"}
|
|
|
|
@mcp_tool(
|
|
name="docker_stop",
|
|
description="Stop a running GhydraMCP Docker container",
|
|
)
|
|
async def docker_stop(
|
|
self, name_or_id: str, remove: bool = True, ctx: Optional[Context] = None
|
|
) -> Dict[str, Any]:
|
|
"""Stop a GhydraMCP Docker container.
|
|
|
|
Args:
|
|
name_or_id: Container name or ID
|
|
remove: Also remove the container (default: True)
|
|
|
|
Returns:
|
|
Status message
|
|
"""
|
|
if not self._check_docker_available():
|
|
return {"error": "Docker is not available on this system"}
|
|
|
|
try:
|
|
# Stop the container
|
|
self._run_docker_cmd(["stop", name_or_id])
|
|
|
|
if remove:
|
|
self._run_docker_cmd(["rm", name_or_id])
|
|
# Remove from tracking
|
|
self._containers = {
|
|
k: v
|
|
for k, v in self._containers.items()
|
|
if not (k.startswith(name_or_id) or v.get("name") == name_or_id)
|
|
}
|
|
return {"success": True, "message": f"Container '{name_or_id}' stopped and removed"}
|
|
else:
|
|
return {"success": True, "message": f"Container '{name_or_id}' stopped"}
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
return {"error": f"Failed to stop container: {e.stderr or e.stdout}"}
|
|
|
|
@mcp_tool(
|
|
name="docker_logs",
|
|
description="Get logs from a GhydraMCP Docker container",
|
|
)
|
|
async def docker_logs(
|
|
self,
|
|
name_or_id: str,
|
|
tail: int = 100,
|
|
follow: bool = False,
|
|
ctx: Optional[Context] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Get logs from a GhydraMCP container.
|
|
|
|
Args:
|
|
name_or_id: Container name or ID
|
|
tail: Number of lines to show (default: 100)
|
|
follow: Whether to follow log output (not recommended for MCP)
|
|
|
|
Returns:
|
|
Container logs
|
|
"""
|
|
if not self._check_docker_available():
|
|
return {"error": "Docker is not available on this system"}
|
|
|
|
try:
|
|
args = ["logs", "--tail", str(tail)]
|
|
if follow:
|
|
args.append("-f")
|
|
args.append(name_or_id)
|
|
|
|
result = self._run_docker_cmd(args)
|
|
return {
|
|
"success": True,
|
|
"container": name_or_id,
|
|
"logs": result.stdout + result.stderr,
|
|
}
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
return {"error": f"Failed to get logs: {e.stderr or e.stdout}"}
|
|
|
|
@mcp_tool(
|
|
name="docker_build",
|
|
description="Build the GhydraMCP Docker image from source",
|
|
)
|
|
async def docker_build(
|
|
self,
|
|
tag: str = "latest",
|
|
no_cache: bool = False,
|
|
project_dir: Optional[str] = None,
|
|
ctx: Optional[Context] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Build the GhydraMCP Docker image.
|
|
|
|
Args:
|
|
tag: Image tag (default: 'latest')
|
|
no_cache: Build without using cache
|
|
project_dir: Path to GhydraMCP project (auto-detected if not specified)
|
|
|
|
Returns:
|
|
Build status
|
|
"""
|
|
if not self._check_docker_available():
|
|
return {"error": "Docker is not available on this system"}
|
|
|
|
# Find project directory
|
|
if project_dir:
|
|
proj_path = Path(project_dir)
|
|
else:
|
|
# Try to find docker/Dockerfile relative to this file
|
|
module_dir = Path(__file__).parent.parent.parent.parent
|
|
if (module_dir / "docker" / "Dockerfile").exists():
|
|
proj_path = module_dir
|
|
else:
|
|
return {
|
|
"error": "Could not find GhydraMCP project directory. Please specify project_dir."
|
|
}
|
|
|
|
dockerfile = proj_path / "docker" / "Dockerfile"
|
|
if not dockerfile.exists():
|
|
return {"error": f"Dockerfile not found at {dockerfile}"}
|
|
|
|
try:
|
|
args = [
|
|
"build",
|
|
"-t",
|
|
f"ghydramcp:{tag}",
|
|
"-f",
|
|
str(dockerfile),
|
|
]
|
|
if no_cache:
|
|
args.append("--no-cache")
|
|
args.append(str(proj_path))
|
|
|
|
# Run build (this can take a while)
|
|
result = self._run_docker_cmd(args, capture=True)
|
|
|
|
return {
|
|
"success": True,
|
|
"image": f"ghydramcp:{tag}",
|
|
"message": f"Successfully built ghydramcp:{tag}",
|
|
"output": result.stdout[-2000:] if len(result.stdout) > 2000 else result.stdout,
|
|
}
|
|
|
|
except subprocess.CalledProcessError as e:
|
|
return {"error": f"Build failed: {e.stderr or e.stdout}"}
|
|
|
|
@mcp_tool(
|
|
name="docker_health",
|
|
description="Check if a GhydraMCP container's API is responding",
|
|
)
|
|
async def docker_health(
|
|
self, port: int = 8192, timeout: float = 5.0, ctx: Optional[Context] = None
|
|
) -> Dict[str, Any]:
|
|
"""Check if a GhydraMCP container's API is healthy.
|
|
|
|
Args:
|
|
port: API port to check (default: 8192)
|
|
timeout: Request timeout in seconds
|
|
|
|
Returns:
|
|
Health status and API info if available
|
|
"""
|
|
import urllib.request
|
|
import urllib.error
|
|
import json
|
|
|
|
url = f"http://localhost:{port}/"
|
|
|
|
try:
|
|
req = urllib.request.Request(url)
|
|
with urllib.request.urlopen(req, timeout=timeout) as response:
|
|
data = json.loads(response.read().decode())
|
|
return {
|
|
"healthy": True,
|
|
"port": port,
|
|
"api_version": data.get("api_version"),
|
|
"program": data.get("program"),
|
|
"file": data.get("file"),
|
|
}
|
|
except urllib.error.URLError as e:
|
|
return {
|
|
"healthy": False,
|
|
"port": port,
|
|
"error": str(e.reason),
|
|
"message": "Container may still be starting or analyzing binary",
|
|
}
|
|
except Exception as e:
|
|
return {
|
|
"healthy": False,
|
|
"port": port,
|
|
"error": str(e),
|
|
}
|
|
|
|
@mcp_tool(
|
|
name="docker_wait",
|
|
description="Wait for a GhydraMCP container to become healthy",
|
|
)
|
|
async def docker_wait(
|
|
self,
|
|
port: int = 8192,
|
|
timeout: float = 300.0,
|
|
interval: float = 5.0,
|
|
ctx: Optional[Context] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Wait for a GhydraMCP container to become healthy.
|
|
|
|
Polls the API endpoint until it responds or timeout is reached.
|
|
|
|
Args:
|
|
port: API port to check (default: 8192)
|
|
timeout: Maximum time to wait in seconds (default: 300)
|
|
interval: Polling interval in seconds (default: 5)
|
|
|
|
Returns:
|
|
Health status once healthy, or error on timeout
|
|
"""
|
|
import time
|
|
|
|
start_time = time.time()
|
|
last_error = None
|
|
|
|
while (time.time() - start_time) < timeout:
|
|
result = await self.docker_health(port=port, timeout=interval, ctx=ctx)
|
|
if result.get("healthy"):
|
|
result["waited_seconds"] = round(time.time() - start_time, 1)
|
|
return result
|
|
last_error = result.get("error")
|
|
await asyncio.sleep(interval)
|
|
|
|
return {
|
|
"healthy": False,
|
|
"port": port,
|
|
"error": f"Timeout after {timeout}s waiting for container",
|
|
"last_error": last_error,
|
|
}
|
|
|
|
@mcp_tool(
|
|
name="docker_auto_start",
|
|
description="Automatically start a GhydraMCP container if no Ghidra instance is available",
|
|
)
|
|
async def docker_auto_start(
|
|
self,
|
|
binary_path: str,
|
|
port: int = 8192,
|
|
wait: bool = True,
|
|
timeout: float = 300.0,
|
|
ctx: Optional[Context] = None,
|
|
) -> Dict[str, Any]:
|
|
"""Automatically start a Docker container if no Ghidra instance is available.
|
|
|
|
This is the main entry point for automatic Docker management:
|
|
1. Checks if a Ghidra instance is already running on the port
|
|
2. If not, starts a new Docker container
|
|
3. Optionally waits for the container to become healthy
|
|
4. Returns connection info for the instance
|
|
|
|
Args:
|
|
binary_path: Path to the binary to analyze
|
|
port: Port for the HTTP API (default: 8192)
|
|
wait: Wait for container to be ready (default: True)
|
|
timeout: Max wait time in seconds (default: 300)
|
|
|
|
Returns:
|
|
Instance connection info
|
|
"""
|
|
# First, check if there's already a Ghidra instance on this port
|
|
health = await self.docker_health(port=port, ctx=ctx)
|
|
if health.get("healthy"):
|
|
return {
|
|
"source": "existing",
|
|
"port": port,
|
|
"api_url": f"http://localhost:{port}/",
|
|
"program": health.get("program"),
|
|
"message": "Using existing Ghidra instance",
|
|
}
|
|
|
|
# Check if Docker is available
|
|
status = await self.docker_status(ctx=ctx)
|
|
if not status.get("docker_running"):
|
|
return {
|
|
"error": "Docker is not available. Please install Docker or start Ghidra manually."
|
|
}
|
|
|
|
# Check if we have the image
|
|
if not any("ghydramcp" in img.get("name", "") for img in status.get("images", [])):
|
|
return {
|
|
"error": (
|
|
"GhydraMCP Docker image not found. "
|
|
"Build it with docker_build() or 'make build' first."
|
|
)
|
|
}
|
|
|
|
# Start a new container
|
|
start_result = await self.docker_start(
|
|
binary_path=binary_path, port=port, ctx=ctx
|
|
)
|
|
|
|
if not start_result.get("success"):
|
|
return start_result
|
|
|
|
if wait:
|
|
# Wait for the container to become healthy
|
|
wait_result = await self.docker_wait(port=port, timeout=timeout, ctx=ctx)
|
|
if wait_result.get("healthy"):
|
|
return {
|
|
"source": "docker",
|
|
"container_id": start_result.get("container_id"),
|
|
"container_name": start_result.get("name"),
|
|
"port": port,
|
|
"api_url": f"http://localhost:{port}/",
|
|
"program": wait_result.get("program"),
|
|
"waited_seconds": wait_result.get("waited_seconds"),
|
|
"message": f"Docker container ready after {wait_result.get('waited_seconds')}s",
|
|
}
|
|
else:
|
|
return {
|
|
"warning": "Container started but not yet healthy",
|
|
"container_id": start_result.get("container_id"),
|
|
"port": port,
|
|
"last_error": wait_result.get("error"),
|
|
"message": "Container may still be analyzing. Check docker_logs() for progress.",
|
|
}
|
|
|
|
return {
|
|
"source": "docker",
|
|
"container_id": start_result.get("container_id"),
|
|
"container_name": start_result.get("name"),
|
|
"port": port,
|
|
"api_url": f"http://localhost:{port}/",
|
|
"message": "Container starting. Use docker_wait() or docker_health() to check status.",
|
|
}
|