use anyhow::{Context, Result}; use std::path::{Path, PathBuf}; use std::time::Duration; const ISCSI_DATA_DIR: &str = "data/iscsi"; const PID_FILE: &str = "data/iscsi/gotgt.pid"; const CONFIG_FILE: &str = ".gotgt/config.json"; const GOTGT_TARGET_PREFIX: &str = "iqn.2026-05.momentry:markbase_"; #[derive(Debug)] pub struct IscsiTargetStatus { pub running: bool, pub pid: Option, pub port: u16, pub target: String, pub lun_path: String, pub lun_size: u64, } fn find_gotgt() -> Result { if let Ok(path) = std::env::var("GOTGT_PATH") { let p = PathBuf::from(&path); if p.exists() { return Ok(p); } } if let Ok(path) = which::which("gotgt") { return Ok(path); } let fallback = dirs::home_dir() .unwrap_or_else(|| PathBuf::from("/")) .join(".local/bin/gotgt"); if fallback.exists() { return Ok(fallback); } anyhow::bail!("gotgt binary not found") } fn ensure_data_dir() -> Result<()> { std::fs::create_dir_all(ISCSI_DATA_DIR)?; let config_dir = dirs::home_dir() .unwrap_or_else(|| PathBuf::from("/")) .join(".gotgt"); std::fs::create_dir_all(config_dir)?; Ok(()) } fn parse_size(s: &str) -> Result { if let Some(n) = s.strip_suffix("TB") { Ok((n.parse::()? * 1_099_511_627_776.0) as u64) } else if let Some(n) = s.strip_suffix("GB") { Ok((n.parse::()? * 1_073_741_824.0) as u64) } else if let Some(n) = s.strip_suffix("MB") { Ok((n.parse::()? * 1_048_576.0) as u64) } else if let Some(n) = s.strip_suffix("KB") { Ok((n.parse::()? * 1024.0) as u64) } else { s.parse::().map_err(|e| anyhow::anyhow!("{e}")) } } fn get_lun_path(user: &str) -> PathBuf { PathBuf::from(ISCSI_DATA_DIR).join(format!("{}_lun.bin", user)) } fn get_pid_path() -> PathBuf { PathBuf::from(PID_FILE) } fn get_config_path() -> PathBuf { dirs::home_dir() .unwrap_or_else(|| PathBuf::from("/")) .join(CONFIG_FILE) } fn get_block_device_size(device: &str) -> Result { let output = std::process::Command::new("diskutil") .arg("info") .arg(device) .output() .context("Failed to run diskutil info")?; let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { if let Some(size_str) = line.strip_prefix(" Disk Size:") { if let Some(bytes) = size_str.trim().split_whitespace().next() { if let Ok(size) = bytes.replace(',', "").parse::() { return Ok(size); } } } } anyhow::bail!("Could not determine size of block device {}", device) } fn is_block_device_mounted(device: &str) -> Result { let output = std::process::Command::new("diskutil") .arg("info") .arg(device) .output() .context("Failed to run diskutil info")?; let stdout = String::from_utf8_lossy(&output.stdout); for line in stdout.lines() { if line.trim_start().starts_with("Mounted:") { return Ok(line.contains("Yes")); } } Ok(false) } pub fn generate_config( user: &str, port: u16, lun_size: &str, force: bool, device: Option<&str>, ) -> Result<()> { ensure_data_dir()?; let config_path = get_config_path(); if config_path.exists() && !force { return Ok(()); } let (storage_path, lun_size_bytes) = if let Some(dev) = device { let dev_path = Path::new(dev); if !dev_path.exists() { anyhow::bail!("Block device not found: {}", dev); } if is_block_device_mounted(dev)? { anyhow::bail!( "Block device {} is mounted. Unmount first with: diskutil unmountDisk {}", dev, dev ); } let size = get_block_device_size(dev)?; (format!("blk:{}", dev), size) } else { let lun_path = get_lun_path(user); let size_bytes = parse_size(lun_size)?; if !lun_path.exists() { let count = size_bytes / 512; let status = std::process::Command::new("dd") .arg("if=/dev/zero") .arg(format!("of={}", lun_path.display())) .arg("bs=512") .arg(format!("count={}", count)) .arg("status=progress") .status() .context("Failed to create LUN disk image")?; if !status.success() { anyhow::bail!("dd failed to create LUN image"); } } (format!("file:{}", lun_path.display()), size_bytes) }; let config = serde_json::json!({ "storages": [{ "deviceID": 1000, "path": storage_path, "online": true, "thinProvisioning": false, "blockShift": 9 }], "iscsiportals": [{ "id": 0, "portal": format!("127.0.0.1:{}", port) }], "iscsitargets": { format!("{}{}", GOTGT_TARGET_PREFIX, user): { "tpgts": { "1": [0] }, "luns": { "0": 1000 } } } }); std::fs::write(&config_path, serde_json::to_string_pretty(&config)?) .with_context(|| format!("Failed to write {}", config_path.display()))?; Ok(()) } pub fn start( user: &str, port: u16, lun_size: &str, force: bool, device: Option<&str>, ) -> Result { let pid_path = get_pid_path(); if pid_path.exists() { if let Ok(pid_str) = std::fs::read_to_string(&pid_path) { if let Ok(pid) = pid_str.trim().parse::() { let alive = std::process::Command::new("kill") .arg("-0") .arg(pid.to_string()) .status() .map(|s| s.success()) .unwrap_or(false); if alive { if !force { anyhow::bail!( "gotgt already running (PID {}). Use --force to restart.", pid ); } let _ = std::process::Command::new("kill") .arg(pid.to_string()) .status(); } } } let _ = std::fs::remove_file(&pid_path); } generate_config(user, port, lun_size, force, device)?; let gotgt_path = find_gotgt()?; let child = std::process::Command::new(&gotgt_path) .arg("daemon") .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .spawn() .context("Failed to start gotgt daemon")?; let pid = child.id(); std::fs::write(&pid_path, pid.to_string()).context("Failed to write PID file")?; std::thread::sleep(Duration::from_millis(500)); let config_path = get_config_path(); let (lun_path, lun_size_out) = if config_path.exists() { let content = std::fs::read_to_string(&config_path)?; let v: serde_json::Value = serde_json::from_str(&content)?; let raw = v["storages"][0]["path"].as_str().unwrap_or("").to_string(); let size = if let Some(dev) = device { get_block_device_size(dev).unwrap_or(0) } else { get_lun_path(user).metadata().map(|m| m.len()).unwrap_or(0) }; (raw, size) } else { (String::new(), 0) }; Ok(IscsiTargetStatus { running: true, pid: Some(pid), port, target: format!("{}{}", GOTGT_TARGET_PREFIX, user), lun_path, lun_size: lun_size_out, }) } pub fn stop() -> Result<()> { let pid_path = get_pid_path(); if !pid_path.exists() { anyhow::bail!("gotgt is not running (no PID file)"); } let pid: u32 = std::fs::read_to_string(&pid_path)?.trim().parse()?; let alive = std::process::Command::new("kill") .arg("-0") .arg(pid.to_string()) .status() .map(|s| s.success()) .unwrap_or(false); if alive { std::process::Command::new("kill") .arg(pid.to_string()) .status() .context("Failed to send SIGTERM")?; for _ in 0..20 { if !std::process::Command::new("kill") .arg("-0") .arg(pid.to_string()) .status() .map(|s| s.success()) .unwrap_or(false) { break; } std::thread::sleep(Duration::from_millis(100)); } } let _ = std::fs::remove_file(&pid_path); Ok(()) } pub fn status() -> Result { let pid_path = get_pid_path(); let config_path = get_config_path(); let pid = pid_path .exists() .then(|| std::fs::read_to_string(&pid_path).ok()) .flatten() .and_then(|s| s.trim().parse::().ok()); let running = pid .map(|p| { std::process::Command::new("kill") .arg("-0") .arg(p.to_string()) .status() .map(|s| s.success()) .unwrap_or(false) }) .unwrap_or(false); let (port, target, lun_path, lun_size) = if config_path.exists() { let content = std::fs::read_to_string(&config_path)?; let v: serde_json::Value = serde_json::from_str(&content)?; let port = v["iscsiportals"][0]["portal"] .as_str() .and_then(|s| s.split(':').nth(1)) .and_then(|p| p.parse().ok()) .unwrap_or(3260); let target = v["iscsitargets"] .as_object() .and_then(|o| o.keys().next().cloned()) .unwrap_or_default(); let raw = v["storages"][0]["path"].as_str().unwrap_or("").to_string(); let (lun, size) = if let Some(dev) = raw.strip_prefix("blk:") { let sz = get_block_device_size(dev).unwrap_or(0); (raw.clone(), sz) } else { let f = raw.strip_prefix("file:").unwrap_or(&raw).to_string(); let sz = PathBuf::from(&f).metadata().map(|m| m.len()).unwrap_or(0); (raw.clone(), sz) }; let lun = lun; (port, target, lun, size) } else { (3260, String::new(), String::new(), 0) }; Ok(IscsiTargetStatus { running, pid, port, target, lun_path, lun_size, }) }