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 │
│ 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
- **Fast queries** - No JVM startup overhead per command
- **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
@@ -181,24 +183,40 @@ The daemon keeps Ghidra loaded in memory. It starts automatically when needed, b
ghidra daemon start --project myproject --program mybinary
# Check daemon status
ghidra daemon status
ghidra daemon status --project myproject
# All commands use the daemon automatically
ghidra function list # Fast!
ghidra decompile main # Fast!
ghidra function list --project myproject # Fast!
ghidra decompile main --project myproject # Fast!
# Stop daemon
ghidra daemon stop
ghidra daemon stop --project myproject
# Restart with different program
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
Default output adapts to context:
- **Interactive (TTY)**: Compact human-readable format
- **Piped/scripted**: Compact JSON for machine parsing
Default output is human-readable in all contexts. Use flags to request machine formats:
- **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:
```bash
+32 -5
View File
@@ -18,12 +18,13 @@ A high-performance Rust CLI for automating Ghidra reverse engineering tasks. Des
## 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 │
│ 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
- **Bridge**: Python script running inside Ghidra, executing commands
- **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
## When to Use
@@ -529,9 +531,34 @@ ghidra daemon start --project myproject --program mybinary --foreground
### Reset State
```bash
# Stop all daemons
# Stop daemon for a specific project
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
# 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│
│ (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
| File | Purpose |
@@ -56,8 +67,11 @@ Import, Analyze, and Quick commands auto-start the daemon:
- **One program per daemon** - Daemon loads a single program
- **Graceful shutdown** - Handles SIGTERM, SIGINT, IPC shutdown command
- **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`
The `{hash}` is computed as `MD5(project_path_string)` ensuring each project has unique socket and lock file names.
## Handlers
Specialized handlers in `handlers/` directory:
+12 -8
View File
@@ -2,9 +2,12 @@
//!
//! Uses local sockets (Unix domain sockets / Windows named pipes) with
//! the new IPC layer instead of TCP.
//!
//! Each project gets its own socket for concurrent daemon operation.
#![allow(dead_code)]
use std::path::Path;
use std::sync::Arc;
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(
bridge: Arc<Mutex<Option<GhidraBridge>>>,
shutdown_tx: broadcast::Sender<()>,
project_path: &Path,
) -> anyhow::Result<()> {
// Create the IPC listener
let listener = transport::create_listener().await
// Create the IPC listener for this project
let listener = transport::create_listener_for_project(project_path).await
.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 mut shutdown_rx = shutdown_tx.subscribe();
@@ -155,8 +159,8 @@ pub async fn run_ipc_server(
}
}
// Clean up socket
transport::remove_socket().ok();
// Clean up socket for this project
transport::remove_socket_for_project(project_path).ok();
Ok(())
}
+2 -1
View File
@@ -86,8 +86,9 @@ pub async fn run(config: DaemonConfig) -> Result<()> {
// Start IPC server task
let ipc_bridge = bridge.clone();
let ipc_shutdown_tx = shutdown_tx.clone();
let ipc_project_path = config.project_path.clone();
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);
}
});
+9 -7
View File
@@ -2,6 +2,8 @@
#![allow(dead_code)]
use std::path::Path;
use anyhow::{Context, Result};
use tokio::io::{ReadHalf, WriteHalf};
@@ -16,13 +18,13 @@ pub struct DaemonClient {
}
impl DaemonClient {
/// Connect to the running daemon.
pub async fn connect() -> Result<Self> {
let stream = transport::connect().await.map_err(|e| {
/// Connect to the running daemon for a specific project.
pub async fn connect(project_path: &Path) -> Result<Self> {
let stream = transport::connect_for_project(project_path).await.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound
|| e.kind() == std::io::ErrorKind::ConnectionRefused
{
anyhow::anyhow!("Daemon not running")
anyhow::anyhow!("Daemon not running for project: {}", project_path.display())
} else {
anyhow::anyhow!("Failed to connect to daemon: {}", e)
}
@@ -182,7 +184,7 @@ impl DaemonClient {
}
}
/// Check if daemon is running (without establishing a full connection).
pub fn daemon_available() -> bool {
transport::socket_exists()
/// Check if daemon is running for a specific project (without establishing a full connection).
pub fn daemon_available(project_path: &Path) -> bool {
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)
//! 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)]
use std::io;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
/// Maximum message size (10 MB)
const MAX_MESSAGE_SIZE: u32 = 10 * 1024 * 1024;
/// Socket name for the daemon
const SOCKET_NAME: &str = "ghidra-cli.sock";
/// Socket name prefix for the daemon
const SOCKET_PREFIX: &str = "ghidra-cli";
// Platform-specific imports and type aliases
#[cfg(unix)]
@@ -30,6 +33,12 @@ pub mod 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.
fn socket_dir() -> io::Result<PathBuf> {
#[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.
pub fn socket_path() -> io::Result<PathBuf> {
/// Checks GHIDRA_CLI_SOCKET env var first (used for testing), then falls back to
/// 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") {
return Ok(PathBuf::from(path));
}
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.
pub fn socket_name() -> String {
pub fn socket_name_for_project(project_path: &Path) -> String {
#[cfg(unix)]
{
// Check env var first (used for testing)
if let Ok(path) = std::env::var("GHIDRA_CLI_SOCKET") {
return path;
}
socket_path()
socket_path_for_project(project_path)
.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)]
{
// Windows uses named pipe namespace
format!("ghidra-cli-{}", std::process::id())
// Windows uses named pipe namespace with project hash
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.
pub fn remove_socket() -> io::Result<()> {
/// Remove the socket file for a specific project if it exists.
pub fn remove_socket_for_project(project_path: &Path) -> io::Result<()> {
#[cfg(unix)]
{
let path = socket_path()?;
let path = socket_path_for_project(project_path)?;
if path.exists() {
std::fs::remove_file(&path)?;
}
@@ -112,32 +127,34 @@ pub fn remove_socket() -> io::Result<()> {
#[cfg(windows)]
{
let _ = project_path; // unused on Windows
Ok(())
}
}
/// Check if the socket exists.
pub fn socket_exists() -> bool {
/// Check if the socket for a specific project exists.
pub fn socket_exists_for_project(project_path: &Path) -> bool {
#[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)]
{
// On Windows, we can't easily check if a named pipe exists
// We'll rely on connection attempts instead
let _ = project_path;
true
}
}
/// Create a listener for incoming IPC connections.
pub async fn create_listener() -> io::Result<Listener> {
/// Create a listener for incoming IPC connections for a specific project.
pub async fn create_listener_for_project(project_path: &Path) -> io::Result<Listener> {
// Ensure socket directory exists and clean up stale socket
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)]
let listener = {
@@ -155,16 +172,16 @@ pub async fn create_listener() -> io::Result<Listener> {
#[cfg(unix)]
{
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))?;
}
Ok(listener)
}
/// Connect to the daemon's IPC socket.
pub async fn connect() -> io::Result<Stream> {
let name = socket_name();
/// Connect to the daemon's IPC socket for a specific project.
pub async fn connect_for_project(project_path: &Path) -> io::Result<Stream> {
let name = socket_name_for_project(project_path);
#[cfg(unix)]
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?;
match ipc::client::DaemonClient::connect().await {
match ipc::client::DaemonClient::connect(&project_path).await {
Ok(mut client) => {
info!("Connected to daemon via IPC");
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)? {
println!("Stopping daemon (PID: {})...", daemon_info.pid);
// Connect via IPC and send shutdown
let mut client = ipc::client::DaemonClient::connect().await?;
// Connect via IPC and send shutdown (using project path for socket)
let mut client = ipc::client::DaemonClient::connect(&project_path).await?;
client.shutdown().await?;
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());
// 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 Some(bridge_running) = status.get("bridge_running").and_then(|v| v.as_bool()) {
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)?;
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?;
println!("Daemon is responsive");
} 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
// SAFETY: Tests run single-threaded (--test-threads=1), so no data race.
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 {
ghidra_cli::ipc::client::DaemonClient::connect().await
ghidra_cli::ipc::client::DaemonClient::connect(project_path).await
})
}