use std::io::{BufRead, BufReader, Seek, SeekFrom};
use std::path::PathBuf;
use std::time::SystemTime;
use void_daemon::config::load_daemon_config;
use void_daemon::control::DaemonInfo;
use void_daemon::{DaemonClient, VoidNode, VoidNodeConfig};
use crate::daemon::CliBlockStore;
use crate::output::{CliError, CliOptions};
use crate::DaemonCommands;
fn daemon_log_dir() -> PathBuf {
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".void")
.join("logs")
}
fn daemon_log_path() -> PathBuf {
let log_dir = daemon_log_dir();
if log_dir.exists() {
if let Ok(entries) = std::fs::read_dir(&log_dir) {
let mut logs: Vec<PathBuf> = entries
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| p.file_name().unwrap_or_default().to_string_lossy().starts_with("daemon.log"))
.collect();
logs.sort();
if let Some(latest) = logs.last() {
return latest.clone();
}
}
}
dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".void")
.join("daemon.log")
}
pub fn run(subcmd: DaemonCommands, opts: &CliOptions) -> Result<(), CliError> {
match subcmd {
DaemonCommands::Start { listen, log_level } => run_start(listen, log_level, opts),
DaemonCommands::Stop => run_stop(opts),
DaemonCommands::Status => run_status(opts),
DaemonCommands::Stats => run_stats(opts),
DaemonCommands::Peers => run_peers(opts),
DaemonCommands::Files => run_files(),
DaemonCommands::Block { cid } => run_block(cid),
DaemonCommands::Add { path } => run_add(path),
DaemonCommands::Cat { cid } => run_cat(cid),
DaemonCommands::Logs { lines, follow } => run_logs(lines, follow),
DaemonCommands::Install { target } => run_install(&target),
}
}
fn run_start(
listen: Option<String>,
log_level: String,
_opts: &CliOptions,
) -> Result<(), CliError> {
if let Some(path) = DaemonInfo::default_path() {
if let Ok(info) = DaemonInfo::load(&path) {
if info.is_alive() {
return Err(CliError::internal(format!(
"daemon already running (pid {}, peer {})",
info.pid, info.peer_id
)));
}
DaemonInfo::remove(&path);
}
}
let log_dir = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".void")
.join("logs");
std::fs::create_dir_all(&log_dir).map_err(|e| CliError::internal(e.to_string()))?;
let file_appender = tracing_appender::rolling::daily(&log_dir, "daemon.log");
let filter = format!("void_daemon={log_level},libp2p=warn");
let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
tracing_subscriber::fmt()
.with_env_filter(&filter)
.with_writer(non_blocking)
.with_ansi(false)
.init();
eprintln!("void daemon starting (log level: {log_level})");
eprintln!("log dir: {}", log_dir.display());
let void_home = dirs::home_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join(".void");
let config_path = void_home.join("daemon.json");
let mut config = if config_path.exists() {
eprintln!("loading config: {}", config_path.display());
load_daemon_config(&config_path)
.map_err(|e| CliError::internal(e))?
} else {
VoidNodeConfig::default()
};
if let Some(addr_str) = listen {
let addr: libp2p::Multiaddr = addr_str
.parse()
.map_err(|e| CliError::internal(format!("invalid listen address: {e}")))?;
config.listen = vec![addr];
}
config.void_dir = Some(void_home.clone());
let rt = tokio::runtime::Builder::new_multi_thread()
.enable_all()
.build()
.map_err(|e| CliError::internal(format!("tokio runtime: {e}")))?;
std::fs::create_dir_all(void_home.join("objects"))
.map_err(|e| CliError::internal(e.to_string()))?;
let store = CliBlockStore::new(&void_home);
let node = rt
.block_on(VoidNode::start(store, config))
.map_err(|e| CliError::internal(format!("daemon start: {e}")))?;
let peer_id = node.peer_id().to_string();
let pid = std::process::id();
let actual_addrs: Vec<String> = rt.block_on(async {
tokio::time::sleep(std::time::Duration::from_millis(500)).await;
let addrs = node.listen_addrs().await;
addrs.iter().map(|a| a.to_string()).collect()
});
if actual_addrs.is_empty() {
return Err(CliError::internal("daemon failed to bind to any address"));
}
let info = DaemonInfo {
peer_id: peer_id.clone(),
listen_addrs: actual_addrs.clone(),
pid,
started_at: SystemTime::now()
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs(),
};
if let Some(path) = DaemonInfo::default_path() {
info.save(&path)
.map_err(|e| CliError::internal(format!("write daemon.json: {e}")))?;
}
eprintln!("peer id: {peer_id}");
eprintln!("pid: {pid}");
for addr in &actual_addrs {
eprintln!("listening on: {addr}");
}
eprintln!("daemon running — press Ctrl+C to stop");
rt.block_on(async {
tokio::signal::ctrl_c().await.ok();
});
eprintln!("\nshutting down...");
node.shutdown();
rt.block_on(async { tokio::time::sleep(std::time::Duration::from_millis(500)).await });
if let Some(path) = DaemonInfo::default_path() {
DaemonInfo::remove(&path);
}
eprintln!("daemon stopped");
Ok(())
}
fn run_stop(_opts: &CliOptions) -> Result<(), CliError> {
let info_path = DaemonInfo::default_path()
.ok_or_else(|| CliError::internal("cannot determine home directory"))?;
let info = DaemonInfo::load(&info_path)
.map_err(|_| CliError::internal("no daemon running (no ~/.void/daemon.json)"))?;
if !info.is_alive() {
DaemonInfo::remove(&info_path);
return Err(CliError::internal(format!(
"daemon pid {} is not running (stale lock file removed)",
info.pid
)));
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| CliError::internal(e.to_string()))?;
match rt.block_on(async {
let client = DaemonClient::connect_from_info(&info).await?;
client.shutdown_daemon().await
}) {
Ok(()) => {
DaemonInfo::remove(&info_path);
eprintln!("daemon stopped");
Ok(())
}
Err(_e) => {
#[cfg(unix)]
{
unsafe {
libc::kill(info.pid as i32, libc::SIGTERM);
}
DaemonInfo::remove(&info_path);
eprintln!("daemon stopped (via signal)");
return Ok(());
}
#[cfg(not(unix))]
Err(CliError::internal(format!("failed to stop daemon: {_e}")))
}
}
}
fn run_status(_opts: &CliOptions) -> Result<(), CliError> {
let info_path = DaemonInfo::default_path()
.ok_or_else(|| CliError::internal("cannot determine home directory"))?;
let info = DaemonInfo::load(&info_path)
.map_err(|_| CliError::internal("no daemon running"))?;
if !info.is_alive() {
DaemonInfo::remove(&info_path);
return Err(CliError::internal("daemon is not running (stale lock file removed)"));
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| CliError::internal(e.to_string()))?;
let status = rt.block_on(async {
let client = DaemonClient::connect_from_info(&info).await?;
client.status().await
}).map_err(|e| CliError::internal(format!("failed to get status: {e}")))?;
match status {
void_daemon::ControlResponse::Status {
peer_id,
peer_count,
uptime_secs,
listen_addrs,
} => {
eprintln!("Daemon running");
eprintln!(" Peer ID: {peer_id}");
eprintln!(" Peers: {peer_count}");
eprintln!(" Uptime: {}s", uptime_secs);
for addr in &listen_addrs {
eprintln!(" Listening: {addr}");
}
}
other => {
eprintln!("Unexpected response: {other:?}");
}
}
Ok(())
}
fn run_stats(_opts: &CliOptions) -> Result<(), CliError> {
let (info, rt) = connect_or_fail()?;
let _ = info;
let stats = rt
.block_on(async {
let client = DaemonClient::connect_from_info(&info).await?;
client.stats().await
})
.map_err(|e| CliError::internal(format!("failed to get stats: {e}")))?;
match stats {
void_daemon::ControlResponse::Stats {
uptime_secs,
peer_count,
requests_have,
requests_block,
requests_get,
blocks_served,
bytes_sent,
bytes_received,
inbound_wantlists,
outbound_responses_queued,
outbound_messages_sent,
handler_errors,
db_lookups_hit,
db_lookups_miss,
} => {
eprintln!("Uptime: {}s", uptime_secs);
eprintln!("Peers: {}", peer_count);
eprintln!("Blocks served: {}", blocks_served);
eprintln!("Bytes sent: {}", format_bytes(bytes_sent));
eprintln!("Bytes received: {}", format_bytes(bytes_received));
eprintln!(
"Requests: have={} block={} get={}",
requests_have, requests_block, requests_get
);
eprintln!("--- Bitswap Diagnostics ---");
eprintln!("Inbound wantlists: {}", inbound_wantlists);
eprintln!("Outbound queued: {}", outbound_responses_queued);
eprintln!("Outbound sent: {}", outbound_messages_sent);
eprintln!("Handler errors: {}", handler_errors);
eprintln!("DB hits/misses: {}/{}", db_lookups_hit, db_lookups_miss);
}
other => eprintln!("Unexpected response: {other:?}"),
}
Ok(())
}
fn run_peers(_opts: &CliOptions) -> Result<(), CliError> {
let (info, rt) = connect_or_fail()?;
let peers = rt
.block_on(async {
let client = DaemonClient::connect_from_info(&info).await?;
client.list_peers().await
})
.map_err(|e| CliError::internal(format!("failed to list peers: {e}")))?;
if peers.is_empty() {
eprintln!("No connected peers");
} else {
eprintln!("Connected peers ({}):", peers.len());
for peer in &peers {
eprintln!(" {}", peer);
}
}
Ok(())
}
fn run_files() -> Result<(), CliError> {
let objects_dir = dirs::home_dir()
.ok_or_else(|| CliError::internal("cannot determine home directory"))?
.join(".void")
.join("objects");
if !objects_dir.exists() {
eprintln!("No blocks stored ({})", objects_dir.display());
return Ok(());
}
let mut count = 0usize;
let mut total_size = 0u64;
for prefix_entry in std::fs::read_dir(&objects_dir).map_err(|e| CliError::internal(e.to_string()))? {
let prefix_entry = prefix_entry.map_err(|e| CliError::internal(e.to_string()))?;
if !prefix_entry.path().is_dir() {
continue;
}
for block_entry in std::fs::read_dir(prefix_entry.path()).map_err(|e| CliError::internal(e.to_string()))? {
let block_entry = block_entry.map_err(|e| CliError::internal(e.to_string()))?;
let path = block_entry.path();
if path.is_file() {
let size = path.metadata().map(|m| m.len()).unwrap_or(0);
let filename = path.file_name().unwrap_or_default().to_string_lossy();
if filename.ends_with(".tmp") {
continue;
}
count += 1;
total_size += size;
eprintln!(" {} {:>8}", filename, format_bytes(size));
}
}
}
eprintln!("\n{} blocks, {} total", count, format_bytes(total_size));
Ok(())
}
fn run_block(cid_str: String) -> Result<(), CliError> {
let objects_dir = dirs::home_dir()
.ok_or_else(|| CliError::internal("cannot determine home directory"))?
.join(".void")
.join("objects");
let path = void_core::store::FsStore::object_path(&objects_dir, &cid_str);
let local = path.exists();
let size = if local {
path.metadata().map(|m| m.len()).unwrap_or(0)
} else {
0
};
eprintln!("CID: {}", cid_str);
if local {
eprintln!("Size: {}", format_bytes(size));
eprintln!("Local: yes");
} else {
eprintln!("Local: no");
}
Ok(())
}
fn run_add(path: String) -> Result<(), CliError> {
let abs_path = std::fs::canonicalize(&path)
.map_err(|e| CliError::internal(format!("invalid path '{}': {e}", path)))?;
let (info, rt) = connect_or_fail()?;
let cid = rt
.block_on(async {
let client = DaemonClient::connect_from_info(&info).await?;
client.add_directory(abs_path.to_str().unwrap_or(&path)).await
})
.map_err(|e| CliError::internal(format!("add failed: {e}")))?;
eprintln!("added {} → {}", path, cid);
println!("{cid}");
Ok(())
}
fn run_cat(cid_str: String) -> Result<(), CliError> {
let cid_bytes = parse_cid_to_bytes(&cid_str)?;
let (info, rt) = connect_or_fail()?;
let data = rt
.block_on(async {
let client = DaemonClient::connect_from_info(&info).await?;
client.cat(cid_bytes).await
})
.map_err(|e| CliError::internal(format!("cat failed: {e}")))?;
use std::io::Write;
std::io::stdout()
.write_all(&data)
.map_err(|e| CliError::internal(e.to_string()))?;
Ok(())
}
fn parse_cid_to_bytes(cid_str: &str) -> Result<Vec<u8>, CliError> {
let cid: ::cid::Cid = cid_str
.parse()
.map_err(|e| CliError::internal(format!("invalid CID '{cid_str}': {e}")))?;
Ok(cid.to_bytes())
}
fn connect_or_fail() -> Result<(DaemonInfo, tokio::runtime::Runtime), CliError> {
let info_path = DaemonInfo::default_path()
.ok_or_else(|| CliError::internal("cannot determine home directory"))?;
let info = DaemonInfo::load(&info_path)
.map_err(|_| CliError::internal("no daemon running (no ~/.void/daemon.json)"))?;
if !info.is_alive() {
DaemonInfo::remove(&info_path);
return Err(CliError::internal(format!(
"daemon pid {} is not running (stale lock file removed)",
info.pid
)));
}
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
.map_err(|e| CliError::internal(e.to_string()))?;
Ok((info, rt))
}
fn format_bytes(bytes: u64) -> String {
if bytes < 1024 {
format!("{} B", bytes)
} else if bytes < 1024 * 1024 {
format!("{:.1} KB", bytes as f64 / 1024.0)
} else if bytes < 1024 * 1024 * 1024 {
format!("{:.1} MB", bytes as f64 / (1024.0 * 1024.0))
} else {
format!("{:.1} GB", bytes as f64 / (1024.0 * 1024.0 * 1024.0))
}
}
fn run_logs(lines: usize, follow: bool) -> Result<(), CliError> {
let log_path = daemon_log_path();
if !log_path.exists() {
return Err(CliError::internal(format!(
"no log file at {}",
log_path.display()
)));
}
let file = std::fs::File::open(&log_path)
.map_err(|e| CliError::internal(format!("open log: {e}")))?;
let reader = BufReader::new(&file);
let all_lines: Vec<String> = reader.lines().filter_map(|l| l.ok()).collect();
let start = all_lines.len().saturating_sub(lines);
for line in &all_lines[start..] {
println!("{line}");
}
if follow {
let mut file = file;
file.seek(SeekFrom::End(0))
.map_err(|e| CliError::internal(e.to_string()))?;
let mut reader = BufReader::new(file);
loop {
let mut line = String::new();
match reader.read_line(&mut line) {
Ok(0) => {
std::thread::sleep(std::time::Duration::from_millis(100));
}
Ok(_) => {
print!("{line}");
}
Err(e) => {
return Err(CliError::internal(format!("read log: {e}")));
}
}
}
}
Ok(())
}
fn run_install(target: &str) -> Result<(), CliError> {
match target {
"systemd" => {
let binary = std::env::current_exe()
.map(|p| p.display().to_string())
.unwrap_or_else(|_| "/usr/local/bin/void".to_string());
let user = std::env::var("USER").unwrap_or_else(|_| "void".to_string());
let home = dirs::home_dir()
.map(|p| p.display().to_string())
.unwrap_or_else(|| format!("/home/{user}"));
let service = format!(
r#"[Unit]
Description=void daemon - encrypted P2P node
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User={user}
WorkingDirectory={home}
ExecStart={binary} daemon start --listen /ip4/0.0.0.0/tcp/4001
Restart=on-failure
RestartSec=10s
StandardOutput=journal
StandardError=journal
Environment="HOME={home}"
# Security hardening
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=read-only
ReadWritePaths={home}/.void
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
"#
);
let service_path = "/etc/systemd/system/void-daemon.service";
eprintln!("Generated systemd service:\n");
eprintln!("{service}");
eprintln!("To install:");
eprintln!(" sudo tee {service_path} <<< '...' # paste the above");
eprintln!(" sudo systemctl daemon-reload");
eprintln!(" sudo systemctl enable --now void-daemon");
eprintln!();
eprintln!("Or pipe directly:");
eprintln!(" void daemon install --target systemd | sudo tee {service_path}");
print!("{service}");
Ok(())
}
other => Err(CliError::internal(format!(
"unsupported target '{other}' (available: systemd)"
))),
}
}