create a socket for each project

This commit is contained in:
Alexander Kiselev
2026-01-26 14:43:22 -08:00
parent 546ad74971
commit 3cb63d1953
9 changed files with 149 additions and 63 deletions
+26 -8
View File
@@ -21,7 +21,8 @@ A high-performance Rust CLI for automating Ghidra reverse engineering tasks, des
``` ```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ CLI Command │────▶│ Daemon (IPC) │────▶│ GhidraBridge │ │ CLI Command │────▶│ Daemon (IPC) │────▶│ GhidraBridge │
│ ghidra ... │ │ Unix socket │ │ TCP to Ghidra │ │ ghidra ... │ │ Per-project │ │ TCP to Ghidra │
│ --project X │ │ Unix socket │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘ └─────────────────┘ └──────────────────┘ └─────────────────┘
@@ -35,6 +36,7 @@ All commands go through the daemon, which maintains a persistent connection to G
- **Consistent state** - Single Ghidra process for all operations - **Consistent state** - Single Ghidra process for all operations
- **Fast queries** - No JVM startup overhead per command - **Fast queries** - No JVM startup overhead per command
- **Auto-start** - Daemon starts automatically when needed - **Auto-start** - Daemon starts automatically when needed
- **Per-project isolation** - Each project gets its own daemon and socket, enabling concurrent analysis of multiple binaries
## Installation ## Installation
@@ -181,24 +183,40 @@ The daemon keeps Ghidra loaded in memory. It starts automatically when needed, b
ghidra daemon start --project myproject --program mybinary ghidra daemon start --project myproject --program mybinary
# Check daemon status # Check daemon status
ghidra daemon status ghidra daemon status --project myproject
# All commands use the daemon automatically # All commands use the daemon automatically
ghidra function list # Fast! ghidra function list --project myproject # Fast!
ghidra decompile main # Fast! ghidra decompile main --project myproject # Fast!
# Stop daemon # Stop daemon
ghidra daemon stop ghidra daemon stop --project myproject
# Restart with different program # Restart with different program
ghidra daemon restart --project myproject --program otherbinary ghidra daemon restart --project myproject --program otherbinary
``` ```
### Multi-Project Support
Each project gets its own daemon process and socket, allowing concurrent analysis:
```bash
# Work on multiple projects simultaneously
ghidra quick ./binary_a --project projA
ghidra quick ./binary_b --project projB
# Query each independently
ghidra function list --project projA
ghidra function list --project projB
```
## Output Formats ## Output Formats
Default output adapts to context: Default output is human-readable in all contexts. Use flags to request machine formats:
- **Interactive (TTY)**: Compact human-readable format
- **Piped/scripted**: Compact JSON for machine parsing - **Default**: Compact human-readable format (designed for both humans and AI agents)
- **--json**: Compact JSON for machine parsing
- **--pretty**: Pretty-printed JSON (indented, multi-line)
Override with flags: Override with flags:
```bash ```bash
+32 -5
View File
@@ -18,12 +18,13 @@ A high-performance Rust CLI for automating Ghidra reverse engineering tasks. Des
## Architecture Overview ## Architecture Overview
ghidra-cli uses a **daemon-only architecture**: ghidra-cli uses a **daemon-only architecture** with **per-project isolation**:
``` ```
┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐ ┌─────────────────┐ ┌──────────────────┐ ┌─────────────────┐
│ CLI Command │────▶│ Daemon (IPC) │────▶│ GhidraBridge │ │ CLI Command │────▶│ Daemon (IPC) │────▶│ GhidraBridge │
│ ghidra ... │ │ Unix socket │ │ TCP to Ghidra │ │ ghidra ... │ │ Per-project │ │ TCP to Ghidra │
│ --project X │ │ Unix socket │ │ │
└─────────────────┘ └──────────────────┘ └─────────────────┘ └─────────────────┘ └──────────────────┘ └─────────────────┘
@@ -37,7 +38,8 @@ ghidra-cli uses a **daemon-only architecture**:
- **Daemon**: Background process managing IPC and Ghidra bridge - **Daemon**: Background process managing IPC and Ghidra bridge
- **Bridge**: Python script running inside Ghidra, executing commands - **Bridge**: Python script running inside Ghidra, executing commands
- **Auto-start**: Daemon starts automatically when needed (import, analyze, quick) - **Auto-start**: Daemon starts automatically when needed (import, analyze, quick)
- **One daemon per project**: Each project gets its own daemon instance - **Per-project sockets**: Each project gets its own socket at `~/.local/share/ghidra-cli/ghidra-cli-{hash}.sock`
- **One daemon per project**: Multiple agents can work on different projects concurrently without conflicts
- **One program per daemon**: Daemon loads a single program for queries - **One program per daemon**: Daemon loads a single program for queries
## When to Use ## When to Use
@@ -529,9 +531,34 @@ ghidra daemon start --project myproject --program mybinary --foreground
### Reset State ### Reset State
```bash ```bash
# Stop all daemons # Stop daemon for a specific project
ghidra daemon stop --project myproject ghidra daemon stop --project myproject
# Remove lock files if needed # Remove lock files if needed (per-project, named by hash)
rm ~/.local/share/ghidra-cli/daemon-*.lock rm ~/.local/share/ghidra-cli/daemon-*.lock
# Remove sockets if needed (per-project, named by hash)
rm /run/user/$UID/ghidra-cli/ghidra-cli-*.sock
# Or on systems without XDG_RUNTIME_DIR:
rm /tmp/ghidra-cli/ghidra-cli-*.sock
``` ```
## Multi-Project Support
ghidra-cli supports concurrent analysis of multiple projects. Each project gets:
- Its own daemon process (identified by lock file)
- Its own Unix socket (named by project path hash)
This allows multiple agents or terminals to work on different binaries without conflicts:
```bash
# Terminal 1: Work on project A
ghidra quick ./binary_a --project projectA
ghidra function list --project projectA
# Terminal 2: Work on project B (concurrently)
ghidra quick ./binary_b --project projectB
ghidra decompile main --project projectB
```
Both daemons run independently and don't interfere with each other.
+15 -1
View File
@@ -7,7 +7,8 @@ The daemon is the central execution authority for ghidra-cli. All commands route
``` ```
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ CLI Client │────▶│ IPC Server │────▶│ Handler │────▶│ GhidraBridge│ │ CLI Client │────▶│ IPC Server │────▶│ Handler │────▶│ GhidraBridge│
│ (DaemonCli) │ │ (Unix sock) │ │ (Routing) │ │ (TCP→Ghidra)│ │ (DaemonCli) │ │ Per-project │ │ (Routing) │ │ (TCP→Ghidra)│
│ │ │ Unix socket │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
@@ -17,6 +18,16 @@ The daemon is the central execution authority for ghidra-cli. All commands route
└─────────────┘ └─────────────┘
``` ```
### Per-Project Socket Isolation
Each project gets its own Unix socket to enable concurrent daemon operation:
- **Socket naming**: `ghidra-cli-{hash}.sock` where `{hash}` is MD5 of project path
- **Socket location**: `$XDG_RUNTIME_DIR/ghidra-cli/` or `/tmp/ghidra-cli/`
- **Lock file naming**: `daemon-{hash}.lock` (same hash for consistency)
This allows multiple agents or terminals to work on different projects without conflicts.
## Key Components ## Key Components
| File | Purpose | | File | Purpose |
@@ -56,8 +67,11 @@ Import, Analyze, and Quick commands auto-start the daemon:
- **One program per daemon** - Daemon loads a single program - **One program per daemon** - Daemon loads a single program
- **Graceful shutdown** - Handles SIGTERM, SIGINT, IPC shutdown command - **Graceful shutdown** - Handles SIGTERM, SIGINT, IPC shutdown command
- **Lock files** - Located at `~/.local/share/ghidra-cli/daemon-{hash}.lock` - **Lock files** - Located at `~/.local/share/ghidra-cli/daemon-{hash}.lock`
- **Socket files** - Located at `$XDG_RUNTIME_DIR/ghidra-cli/ghidra-cli-{hash}.sock`
- **Logs** - Located at `~/.local/share/ghidra-cli/daemon.log` - **Logs** - Located at `~/.local/share/ghidra-cli/daemon.log`
The `{hash}` is computed as `MD5(project_path_string)` ensuring each project has unique socket and lock file names.
## Handlers ## Handlers
Specialized handlers in `handlers/` directory: Specialized handlers in `handlers/` directory:
+10 -6
View File
@@ -2,9 +2,12 @@
//! //!
//! Uses local sockets (Unix domain sockets / Windows named pipes) with //! Uses local sockets (Unix domain sockets / Windows named pipes) with
//! the new IPC layer instead of TCP. //! the new IPC layer instead of TCP.
//!
//! Each project gets its own socket for concurrent daemon operation.
#![allow(dead_code)] #![allow(dead_code)]
use std::path::Path;
use std::sync::Arc; use std::sync::Arc;
use std::time::Instant; use std::time::Instant;
@@ -108,16 +111,17 @@ impl IpcServer {
} }
} }
/// Run the IPC server. /// Run the IPC server for a specific project.
pub async fn run_ipc_server( pub async fn run_ipc_server(
bridge: Arc<Mutex<Option<GhidraBridge>>>, bridge: Arc<Mutex<Option<GhidraBridge>>>,
shutdown_tx: broadcast::Sender<()>, shutdown_tx: broadcast::Sender<()>,
project_path: &Path,
) -> anyhow::Result<()> { ) -> anyhow::Result<()> {
// Create the IPC listener // Create the IPC listener for this project
let listener = transport::create_listener().await let listener = transport::create_listener_for_project(project_path).await
.map_err(|e| anyhow::anyhow!("Failed to create IPC listener: {}", e))?; .map_err(|e| anyhow::anyhow!("Failed to create IPC listener: {}", e))?;
info!("IPC server listening on {}", transport::socket_name()); info!("IPC server listening on {}", transport::socket_name_for_project(project_path));
let server = Arc::new(IpcServer::new(bridge, shutdown_tx.clone())); let server = Arc::new(IpcServer::new(bridge, shutdown_tx.clone()));
let mut shutdown_rx = shutdown_tx.subscribe(); let mut shutdown_rx = shutdown_tx.subscribe();
@@ -155,8 +159,8 @@ pub async fn run_ipc_server(
} }
} }
// Clean up socket // Clean up socket for this project
transport::remove_socket().ok(); transport::remove_socket_for_project(project_path).ok();
Ok(()) Ok(())
} }
+2 -1
View File
@@ -86,8 +86,9 @@ pub async fn run(config: DaemonConfig) -> Result<()> {
// Start IPC server task // Start IPC server task
let ipc_bridge = bridge.clone(); let ipc_bridge = bridge.clone();
let ipc_shutdown_tx = shutdown_tx.clone(); let ipc_shutdown_tx = shutdown_tx.clone();
let ipc_project_path = config.project_path.clone();
let ipc_handle = tokio::spawn(async move { let ipc_handle = tokio::spawn(async move {
if let Err(e) = ipc_server::run_ipc_server(ipc_bridge, ipc_shutdown_tx).await { if let Err(e) = ipc_server::run_ipc_server(ipc_bridge, ipc_shutdown_tx, &ipc_project_path).await {
error!("IPC server error: {}", e); error!("IPC server error: {}", e);
} }
}); });
+9 -7
View File
@@ -2,6 +2,8 @@
#![allow(dead_code)] #![allow(dead_code)]
use std::path::Path;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use tokio::io::{ReadHalf, WriteHalf}; use tokio::io::{ReadHalf, WriteHalf};
@@ -16,13 +18,13 @@ pub struct DaemonClient {
} }
impl DaemonClient { impl DaemonClient {
/// Connect to the running daemon. /// Connect to the running daemon for a specific project.
pub async fn connect() -> Result<Self> { pub async fn connect(project_path: &Path) -> Result<Self> {
let stream = transport::connect().await.map_err(|e| { let stream = transport::connect_for_project(project_path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::ConnectionRefused || e.kind() == std::io::ErrorKind::ConnectionRefused
{ {
anyhow::anyhow!("Daemon not running") anyhow::anyhow!("Daemon not running for project: {}", project_path.display())
} else { } else {
anyhow::anyhow!("Failed to connect to daemon: {}", e) anyhow::anyhow!("Failed to connect to daemon: {}", e)
} }
@@ -182,7 +184,7 @@ impl DaemonClient {
} }
} }
/// Check if daemon is running (without establishing a full connection). /// Check if daemon is running for a specific project (without establishing a full connection).
pub fn daemon_available() -> bool { pub fn daemon_available(project_path: &Path) -> bool {
transport::socket_exists() transport::socket_exists_for_project(project_path)
} }
+44 -27
View File
@@ -2,18 +2,21 @@
//! //!
//! Abstracts Unix domain sockets (Unix/macOS) and named pipes (Windows) //! Abstracts Unix domain sockets (Unix/macOS) and named pipes (Windows)
//! using the interprocess crate. Uses length-prefixed message framing. //! using the interprocess crate. Uses length-prefixed message framing.
//!
//! Socket paths are per-project to allow concurrent daemons for different
//! projects without conflicts. Socket names use MD5 hash of the project path.
#![allow(dead_code)] #![allow(dead_code)]
use std::io; use std::io;
use std::path::PathBuf; use std::path::{Path, PathBuf};
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Maximum message size (10 MB) /// Maximum message size (10 MB)
const MAX_MESSAGE_SIZE: u32 = 10 * 1024 * 1024; const MAX_MESSAGE_SIZE: u32 = 10 * 1024 * 1024;
/// Socket name for the daemon /// Socket name prefix for the daemon
const SOCKET_NAME: &str = "ghidra-cli.sock"; const SOCKET_PREFIX: &str = "ghidra-cli";
// Platform-specific imports and type aliases // Platform-specific imports and type aliases
#[cfg(unix)] #[cfg(unix)]
@@ -30,6 +33,12 @@ pub mod platform {
pub use platform::*; pub use platform::*;
/// Compute MD5 hash of project path for socket naming.
/// Uses same hashing approach as lock files for consistency.
fn project_hash(project_path: &Path) -> String {
format!("{:x}", md5::compute(project_path.to_string_lossy().as_bytes()))
}
/// Get the socket directory path. /// Get the socket directory path.
fn socket_dir() -> io::Result<PathBuf> { fn socket_dir() -> io::Result<PathBuf> {
#[cfg(unix)] #[cfg(unix)]
@@ -49,36 +58,42 @@ fn socket_dir() -> io::Result<PathBuf> {
} }
} }
/// Get the socket path. /// Get the socket path for a specific project.
/// ///
/// Checks GHIDRA_CLI_SOCKET env var first (used for testing), then falls back to default. /// Checks GHIDRA_CLI_SOCKET env var first (used for testing), then falls back to
pub fn socket_path() -> io::Result<PathBuf> { /// project-specific socket using MD5 hash of project path.
pub fn socket_path_for_project(project_path: &Path) -> io::Result<PathBuf> {
if let Ok(path) = std::env::var("GHIDRA_CLI_SOCKET") { if let Ok(path) = std::env::var("GHIDRA_CLI_SOCKET") {
return Ok(PathBuf::from(path)); return Ok(PathBuf::from(path));
} }
let dir = socket_dir()?; let dir = socket_dir()?;
Ok(dir.join(SOCKET_NAME)) let hash = project_hash(project_path);
Ok(dir.join(format!("{}-{}.sock", SOCKET_PREFIX, hash)))
} }
/// Get the socket name for interprocess. /// Get the socket name for interprocess, for a specific project.
/// ///
/// On Unix, respects GHIDRA_CLI_SOCKET env var for test isolation. /// On Unix, respects GHIDRA_CLI_SOCKET env var for test isolation.
pub fn socket_name() -> String { pub fn socket_name_for_project(project_path: &Path) -> String {
#[cfg(unix)] #[cfg(unix)]
{ {
// Check env var first (used for testing) // Check env var first (used for testing)
if let Ok(path) = std::env::var("GHIDRA_CLI_SOCKET") { if let Ok(path) = std::env::var("GHIDRA_CLI_SOCKET") {
return path; return path;
} }
socket_path() socket_path_for_project(project_path)
.map(|p| p.to_string_lossy().to_string()) .map(|p| p.to_string_lossy().to_string())
.unwrap_or_else(|_| format!("/tmp/ghidra-cli/{}", SOCKET_NAME)) .unwrap_or_else(|_| {
let hash = project_hash(project_path);
format!("/tmp/ghidra-cli/{}-{}.sock", SOCKET_PREFIX, hash)
})
} }
#[cfg(windows)] #[cfg(windows)]
{ {
// Windows uses named pipe namespace // Windows uses named pipe namespace with project hash
format!("ghidra-cli-{}", std::process::id()) let hash = project_hash(project_path);
format!("{}-{}", SOCKET_PREFIX, hash)
} }
} }
@@ -99,11 +114,11 @@ pub fn ensure_socket_dir() -> io::Result<()> {
} }
} }
/// Remove the socket file if it exists. /// Remove the socket file for a specific project if it exists.
pub fn remove_socket() -> io::Result<()> { pub fn remove_socket_for_project(project_path: &Path) -> io::Result<()> {
#[cfg(unix)] #[cfg(unix)]
{ {
let path = socket_path()?; let path = socket_path_for_project(project_path)?;
if path.exists() { if path.exists() {
std::fs::remove_file(&path)?; std::fs::remove_file(&path)?;
} }
@@ -112,32 +127,34 @@ pub fn remove_socket() -> io::Result<()> {
#[cfg(windows)] #[cfg(windows)]
{ {
let _ = project_path; // unused on Windows
Ok(()) Ok(())
} }
} }
/// Check if the socket exists. /// Check if the socket for a specific project exists.
pub fn socket_exists() -> bool { pub fn socket_exists_for_project(project_path: &Path) -> bool {
#[cfg(unix)] #[cfg(unix)]
{ {
socket_path().map(|p| p.exists()).unwrap_or(false) socket_path_for_project(project_path).map(|p| p.exists()).unwrap_or(false)
} }
#[cfg(windows)] #[cfg(windows)]
{ {
// On Windows, we can't easily check if a named pipe exists // On Windows, we can't easily check if a named pipe exists
// We'll rely on connection attempts instead // We'll rely on connection attempts instead
let _ = project_path;
true true
} }
} }
/// Create a listener for incoming IPC connections. /// Create a listener for incoming IPC connections for a specific project.
pub async fn create_listener() -> io::Result<Listener> { pub async fn create_listener_for_project(project_path: &Path) -> io::Result<Listener> {
// Ensure socket directory exists and clean up stale socket // Ensure socket directory exists and clean up stale socket
ensure_socket_dir()?; ensure_socket_dir()?;
remove_socket()?; remove_socket_for_project(project_path)?;
let name = socket_name(); let name = socket_name_for_project(project_path);
#[cfg(unix)] #[cfg(unix)]
let listener = { let listener = {
@@ -155,16 +172,16 @@ pub async fn create_listener() -> io::Result<Listener> {
#[cfg(unix)] #[cfg(unix)]
{ {
use std::os::unix::fs::PermissionsExt; use std::os::unix::fs::PermissionsExt;
let path = socket_path()?; let path = socket_path_for_project(project_path)?;
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?; std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600))?;
} }
Ok(listener) Ok(listener)
} }
/// Connect to the daemon's IPC socket. /// Connect to the daemon's IPC socket for a specific project.
pub async fn connect() -> io::Result<Stream> { pub async fn connect_for_project(project_path: &Path) -> io::Result<Stream> {
let name = socket_name(); let name = socket_name_for_project(project_path);
#[cfg(unix)] #[cfg(unix)]
let stream = { let stream = {
+5 -5
View File
@@ -127,7 +127,7 @@ async fn run_with_daemon_check(cli: Cli) -> anyhow::Result<()> {
ensure_daemon_running(&project_path).await?; ensure_daemon_running(&project_path).await?;
match ipc::client::DaemonClient::connect().await { match ipc::client::DaemonClient::connect(&project_path).await {
Ok(mut client) => { Ok(mut client) => {
info!("Connected to daemon via IPC"); info!("Connected to daemon via IPC");
let output = execute_via_daemon(&mut client, &cli.command, cli.json, cli.pretty).await?; let output = execute_via_daemon(&mut client, &cli.command, cli.json, cli.pretty).await?;
@@ -430,8 +430,8 @@ async fn handle_daemon_stop(project: Option<String>) -> anyhow::Result<()> {
if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { if let Some(daemon_info) = get_running_daemon_info(&data_dir, &project_path)? {
println!("Stopping daemon (PID: {})...", daemon_info.pid); println!("Stopping daemon (PID: {})...", daemon_info.pid);
// Connect via IPC and send shutdown // Connect via IPC and send shutdown (using project path for socket)
let mut client = ipc::client::DaemonClient::connect().await?; let mut client = ipc::client::DaemonClient::connect(&project_path).await?;
client.shutdown().await?; client.shutdown().await?;
println!("Daemon stopped successfully"); println!("Daemon stopped successfully");
@@ -468,7 +468,7 @@ async fn handle_daemon_status(project: Option<String>) -> anyhow::Result<()> {
println!(" Log file: {}", daemon_info.log_file.display()); println!(" Log file: {}", daemon_info.log_file.display());
// Try to get detailed status from daemon via IPC // Try to get detailed status from daemon via IPC
if let Ok(mut client) = ipc::client::DaemonClient::connect().await { if let Ok(mut client) = ipc::client::DaemonClient::connect(&project_path).await {
if let Ok(status) = client.status().await { if let Ok(status) = client.status().await {
if let Some(bridge_running) = status.get("bridge_running").and_then(|v| v.as_bool()) { if let Some(bridge_running) = status.get("bridge_running").and_then(|v| v.as_bool()) {
println!(" Bridge: {}", if bridge_running { "running" } else { "stopped" }); println!(" Bridge: {}", if bridge_running { "running" } else { "stopped" });
@@ -489,7 +489,7 @@ async fn handle_daemon_ping(project: Option<String>) -> anyhow::Result<()> {
let project_path = resolve_project_path(&project, &config)?; let project_path = resolve_project_path(&project, &config)?;
if let Some(_daemon_info) = get_running_daemon_info(&data_dir, &project_path)? { if let Some(_daemon_info) = get_running_daemon_info(&data_dir, &project_path)? {
let mut client = ipc::client::DaemonClient::connect().await?; let mut client = ipc::client::DaemonClient::connect(&project_path).await?;
client.ping().await?; client.ping().await?;
println!("Daemon is responsive"); println!("Daemon is responsive");
} else { } else {
+4 -1
View File
@@ -191,8 +191,11 @@ impl DaemonTestHarness {
// Set GHIDRA_CLI_SOCKET for this process so client connects to the right socket // Set GHIDRA_CLI_SOCKET for this process so client connects to the right socket
// SAFETY: Tests run single-threaded (--test-threads=1), so no data race. // SAFETY: Tests run single-threaded (--test-threads=1), so no data race.
unsafe { std::env::set_var("GHIDRA_CLI_SOCKET", &self.socket_path); } unsafe { std::env::set_var("GHIDRA_CLI_SOCKET", &self.socket_path); }
// When GHIDRA_CLI_SOCKET is set, the project path is ignored
// but we still need to pass one for the function signature
let project_path = std::path::Path::new(&self.project);
self.runtime.block_on(async { self.runtime.block_on(async {
ghidra_cli::ipc::client::DaemonClient::connect().await ghidra_cli::ipc::client::DaemonClient::connect(project_path).await
}) })
} }