From 3cb63d1953bcd83ebf4c14118bef6e3f4d2d4613 Mon Sep 17 00:00:00 2001 From: Alexander Kiselev Date: Mon, 26 Jan 2026 14:43:22 -0800 Subject: [PATCH] create a socket for each project --- README.md | 34 ++++++++++++++----- SKILL.md | 37 ++++++++++++++++++--- src/daemon/README.md | 16 ++++++++- src/daemon/ipc_server.rs | 20 ++++++----- src/daemon/mod.rs | 3 +- src/ipc/client.rs | 16 +++++---- src/ipc/transport.rs | 71 +++++++++++++++++++++++++--------------- src/main.rs | 10 +++--- tests/common/mod.rs | 5 ++- 9 files changed, 149 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index cafec53..872d97b 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/SKILL.md b/SKILL.md index fc36b32..8b604f1 100644 --- a/SKILL.md +++ b/SKILL.md @@ -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. diff --git a/src/daemon/README.md b/src/daemon/README.md index c166c24..040d855 100644 --- a/src/daemon/README.md +++ b/src/daemon/README.md @@ -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: diff --git a/src/daemon/ipc_server.rs b/src/daemon/ipc_server.rs index d1d52a1..302e94b 100644 --- a/src/daemon/ipc_server.rs +++ b/src/daemon/ipc_server.rs @@ -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>>, 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(()) } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 75646c5..84b035d 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -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); } }); diff --git a/src/ipc/client.rs b/src/ipc/client.rs index 626f5c0..c53822f 100644 --- a/src/ipc/client.rs +++ b/src/ipc/client.rs @@ -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 { - 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 { + 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) } diff --git a/src/ipc/transport.rs b/src/ipc/transport.rs index cdd11ad..c77684d 100644 --- a/src/ipc/transport.rs +++ b/src/ipc/transport.rs @@ -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 { #[cfg(unix)] @@ -49,36 +58,42 @@ fn socket_dir() -> io::Result { } } -/// 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 { +/// 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 { 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 { +/// Create a listener for incoming IPC connections for a specific project. +pub async fn create_listener_for_project(project_path: &Path) -> io::Result { // 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 { #[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 { - 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 { + let name = socket_name_for_project(project_path); #[cfg(unix)] let stream = { diff --git a/src/main.rs b/src/main.rs index ceadefb..28db14c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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) -> 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) -> 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) -> 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 { diff --git a/tests/common/mod.rs b/tests/common/mod.rs index d6b3745..2225201 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -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 }) }