From 678865bfdc53a1501d3037f83999b0570946bfb9 Mon Sep 17 00:00:00 2001 From: Eric Wendland Date: Sat, 18 Jul 2026 16:31:59 +0200 Subject: [PATCH] Harden daemon service installation --- README.md | 17 +- crates/geth-cli/src/lib.rs | 366 ++++++++++++++++++++++++++--- crates/geth-node/src/service.rs | 402 +++++++++++++++++++++++++++++++- docs/architecture.md | 10 +- docs/roadmap.md | 23 +- docs/user-workflows.md | 12 +- 6 files changed, 784 insertions(+), 46 deletions(-) diff --git a/README.md b/README.md index cd128e3..eff56f5 100644 --- a/README.md +++ b/README.md @@ -25,7 +25,8 @@ Keep the daemon running. In a second shell, reuse the home it printed: Ctrl-C stops the daemon and removes its temporary state. For a persistent background node, run `geth daemon install`; this initializes local state, -installs a service for the current user, and starts it immediately. Before +installs a service for the current user, starts it, and waits for the daemon to +answer on local control. Before enrolling other machines, use `geth guide owner-setup`; it records an OpenSSH admin public key as the trust anchor and signs the initial keychain statements without copying the private key into geth state. @@ -92,6 +93,7 @@ The daemon can also install itself as a user service: ```sh geth daemon install geth daemon status +geth daemon logs geth daemon uninstall ``` @@ -100,6 +102,16 @@ agents on macOS, and per-user scheduled tasks on Windows. These are user-level services, not system services. The longer `geth daemon service ...` family is retained for compatibility and advanced options. +`daemon install` canonicalizes and validates the executable recorded in the +service definition. When invoked from a Cargo `target` directory or another +temporary location, it atomically copies the binary into +`/bin/geth` first so cleanup cannot leave a broken service. Use +`--allow-transient-binary` only for deliberate development setups. Installation +waits up to 30 seconds for local daemon readiness by default; use `--no-wait` or +`--timeout-ms` when automation needs different behavior. `geth daemon logs` +shows the systemd user journal or the selected home's launchd/Windows log files; +add `--follow` to stream new entries. + ## Transport And SSH All remote node-to-node geth communication is designed to happen over Iroh only. @@ -139,8 +151,9 @@ The bootstrap implementation provides: - `geth init` - `geth init --admin-key --signing-key --node-name ` - `geth daemon run [--ephemeral]` -- `geth daemon install|start|stop|status|uninstall` +- `geth daemon install|start|stop|status|logs|uninstall` - `geth daemon service install|uninstall|start|stop|status|print` +- `geth config path|show|validate|set` - `geth status` - `geth wait daemon|peer|sync --timeout-ms ` - `geth doctor` diff --git a/crates/geth-cli/src/lib.rs b/crates/geth-cli/src/lib.rs index a08a331..4702249 100644 --- a/crates/geth-cli/src/lib.rs +++ b/crates/geth-cli/src/lib.rs @@ -4,7 +4,9 @@ use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum}; use clap_complete::{Shell, generate}; use geth_config::{ConfigKey, GethConfig, GethPaths}; use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus}; -use geth_node::service::{ServiceInstallOptions, ServiceManager, ServiceReport}; +use geth_node::service::{ + ServiceInstallOptions, ServiceLogState, ServiceLogsReport, ServiceManager, ServiceReport, +}; use std::io::{Read, Write, stdout}; use std::path::PathBuf; use std::time::{Duration, Instant}; @@ -24,6 +26,7 @@ Use `geth --help` for details and `geth guide` for complete workflows. const DAEMON_AFTER_HELP: &str = r#"Examples: geth daemon install # persistent user service; starts immediately geth daemon status + geth daemon logs geth daemon stop geth daemon start geth daemon uninstall @@ -241,6 +244,8 @@ const GUIDE_SERVICE: &str = r#"Install geth as a user service: geth daemon install geth daemon status + geth daemon logs + geth daemon logs --follow geth daemon stop geth daemon start geth daemon uninstall @@ -250,6 +255,12 @@ Service installation targets user service managers, not system services: macOS: launchd user agent Windows: current-user scheduled task +`daemon install` waits for the local control endpoint to become ready. If the +current executable is under a Cargo target or temporary directory, geth copies +it into `/bin` before creating the service definition so `cargo +clean` or temporary-file cleanup cannot break the service. Pass +`--allow-transient-binary` only when that direct reference is intentional. + Preview definitions without installing: geth daemon service print @@ -590,6 +601,22 @@ pub enum DaemonCommand { manager: String, #[arg(long, help = "Executable path stored in the service definition")] bin: Option, + #[arg( + long, + help = "Reference a temporary/Cargo-target binary directly instead of copying it into the geth home" + )] + allow_transient_binary: bool, + #[arg( + long, + help = "Return after starting without waiting for daemon readiness" + )] + no_wait: bool, + #[arg( + long, + default_value_t = 30_000, + help = "Maximum readiness wait in milliseconds" + )] + timeout_ms: u64, }, /// Start the installed user service Start { @@ -606,6 +633,20 @@ pub enum DaemonCommand { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] manager: String, }, + /// Show recent user-service logs or follow them + Logs { + #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] + manager: String, + #[arg( + long, + default_value_t = 100, + value_parser = parse_log_line_count, + help = "Recent lines to show per log source" + )] + lines: usize, + #[arg(long, help = "Continue following new log entries")] + follow: bool, + }, /// Stop and remove the installed user service Uninstall { #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] @@ -628,6 +669,11 @@ pub enum ServiceCommand { bin: Option, #[arg(long, help = "Start the service immediately after installation")] start: bool, + #[arg( + long, + help = "Reference a temporary/Cargo-target binary directly instead of copying it into the geth home" + )] + allow_transient_binary: bool, }, /// Stop and remove the user service Uninstall { @@ -1765,7 +1811,14 @@ async fn run_inner(cli: Cli) -> Result<()> { .context("run geth daemon")?; } Command::Daemon { - command: DaemonCommand::Install { manager, bin }, + command: + DaemonCommand::Install { + manager, + bin, + allow_transient_binary, + no_wait, + timeout_ms, + }, } => { let report = run_service_command( &paths, @@ -1773,45 +1826,89 @@ async fn run_inner(cli: Cli) -> Result<()> { manager, bin, start: true, + allow_transient_binary, }, ) .context("install and start geth user service")?; - print_service_report(report, cli.json || cli.jsonl)?; + let readiness = if no_wait { + None + } else { + Some( + run_wait_command( + &paths, + WaitCommand::Daemon { + timeout_ms, + interval_ms: 250, + }, + ) + .await?, + ) + }; + print_daemon_install_report(&report, readiness.as_ref(), cli.json || cli.jsonl)?; + if readiness.as_ref().is_some_and(|readiness| !readiness.ready) { + std::process::exit(1); + } } Command::Daemon { command: DaemonCommand::Start { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Start { manager }) .context("start geth user service; install it first with `geth daemon install`")?; - print_service_report(report, cli.json || cli.jsonl)?; + print_service_report(&report, cli.json || cli.jsonl)?; } Command::Daemon { command: DaemonCommand::Stop { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Stop { manager }) .context("stop geth user service")?; - print_service_report(report, cli.json || cli.jsonl)?; + print_service_report(&report, cli.json || cli.jsonl)?; } Command::Daemon { command: DaemonCommand::Status { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Status { manager }) .context("query geth user service; install it with `geth daemon install`")?; - print_service_report(report, cli.json || cli.jsonl)?; + print_service_report(&report, cli.json || cli.jsonl)?; + } + Command::Daemon { + command: + DaemonCommand::Logs { + manager, + lines, + follow, + }, + } => { + if follow && (cli.json || cli.jsonl) { + bail!("--follow cannot be combined with --json or --jsonl"); + } + let report = geth_node::service::logs_user_service( + &paths, + manager.parse::()?, + lines, + follow, + ) + .context("read geth user-service logs")?; + print_service_logs_report(&report, cli.json || cli.jsonl)?; + if matches!( + report.state, + ServiceLogState::NotInstalled | ServiceLogState::Unavailable + ) { + std::process::exit(1); + } } Command::Daemon { command: DaemonCommand::Uninstall { manager }, } => { let report = run_service_command(&paths, ServiceCommand::Uninstall { manager }) .context("uninstall geth user service")?; - print_service_report(report, cli.json || cli.jsonl)?; + print_service_report(&report, cli.json || cli.jsonl)?; } Command::Daemon { command: DaemonCommand::Service { command }, } => { let report = run_service_command(&paths, command).context("manage geth user service")?; - print_service_report(report, cli.json || cli.jsonl)?; + print_service_report(&report, cli.json || cli.jsonl)?; } Command::Wait { command } => { let report = run_wait_command(&paths, command).await?; @@ -3162,18 +3259,27 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result { - geth_node::init_node(paths).context("initialize geth home before service install")?; let manager = manager.parse::()?; - let executable = service_executable(bin)?; - geth_node::service::install_user_service( + geth_node::init_node(paths).context("initialize geth home before service install")?; + let (executable, copied_from) = service_executable(paths, bin, allow_transient_binary)?; + let mut report = geth_node::service::install_user_service( paths, ServiceInstallOptions { manager, executable, start, }, - )? + )?; + if let Some(source) = copied_from { + report.note = format!( + "{}; copied transient executable from {} into the selected geth home", + report.note, + source.display() + ); + } + report } ServiceCommand::Uninstall { manager } => { geth_node::service::uninstall_user_service(manager.parse::()?)? @@ -3188,7 +3294,10 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result()?)? } ServiceCommand::Print { manager, bin } => { - let executable = service_executable(bin)?; + let executable = bin + .map(Ok) + .unwrap_or_else(std::env::current_exe) + .context("resolve geth executable for service preview")?; geth_node::service::print_user_service( paths, manager.parse::()?, @@ -3198,10 +3307,82 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result) -> Result { - bin.map(Ok) +fn parse_log_line_count(value: &str) -> std::result::Result { + value + .parse::() + .ok() + .filter(|lines| *lines > 0) + .ok_or_else(|| "log line count must be a positive integer".to_owned()) +} + +fn service_executable( + paths: &GethPaths, + bin: Option, + allow_transient: bool, +) -> Result<(PathBuf, Option)> { + let executable = bin + .map(Ok) .unwrap_or_else(std::env::current_exe) - .context("resolve current geth executable") + .context("resolve current geth executable")?; + let executable = executable + .canonicalize() + .with_context(|| format!("resolve service executable {}", executable.display()))?; + if !executable.is_file() { + bail!( + "service executable is not a regular file: {}", + executable.display() + ); + } + if !allow_transient && transient_executable_reason(&executable).is_some() { + let bin_dir = paths.home().join("bin"); + std::fs::create_dir_all(&bin_dir).context("create durable service binary directory")?; + let file_name = if cfg!(windows) { "geth.exe" } else { "geth" }; + let destination = bin_dir.join(file_name); + let mut source = std::fs::File::open(&executable) + .with_context(|| format!("open service executable {}", executable.display()))?; + let permissions = source + .metadata() + .context("read service executable metadata")? + .permissions(); + let mut temporary = tempfile::NamedTempFile::new_in(&bin_dir) + .context("create temporary service executable")?; + std::io::copy(&mut source, temporary.as_file_mut()) + .context("copy service executable into geth home")?; + temporary + .as_file_mut() + .set_permissions(permissions) + .context("preserve service executable permissions")?; + temporary + .as_file_mut() + .sync_all() + .context("flush copied service executable")?; + temporary + .persist(&destination) + .map_err(|error| error.error) + .with_context(|| { + format!( + "install durable service executable at {}", + destination.display() + ) + })?; + return Ok((destination, Some(executable))); + } + Ok((executable, None)) +} + +fn transient_executable_reason(executable: &std::path::Path) -> Option<&'static str> { + if executable.starts_with(std::env::temp_dir()) { + return Some("temporary files can be removed while the service still references them"); + } + let components = executable + .components() + .filter_map(|component| component.as_os_str().to_str()) + .map(|component| component.to_ascii_lowercase()) + .collect::>(); + components.windows(2).find_map(|pair| { + (pair[0] == "target" && matches!(pair[1].as_str(), "debug" | "release" | "deps")) + .then_some("Cargo target artifacts are not a durable service installation path") + }) } fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) { @@ -4849,40 +5030,84 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> { Ok(()) } -fn print_service_report(report: ServiceReport, json: bool) -> Result<()> { +fn print_daemon_install_report( + report: &ServiceReport, + readiness: Option<&WaitReport>, + json: bool, +) -> Result<()> { + if json { + let readiness = readiness.map(wait_report_json); + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "type": "daemon-install", + "service": service_report_json(report), + "readiness": readiness, + }))? + ); + return Ok(()); + } + print_service_report(report, false)?; + if let Some(readiness) = readiness { + println!("readiness:"); + println!(" ready: {}", readiness.ready); + println!(" elapsed_ms: {}", readiness.elapsed_ms); + println!(" reason: {}", readiness.reason); + if !readiness.ready { + println!(" next: inspect logs with `geth daemon logs`"); + } + } + Ok(()) +} + +fn wait_report_json(report: &WaitReport) -> serde_json::Value { + serde_json::json!({ + "target": report.target, + "ready": report.ready, + "elapsed_ms": report.elapsed_ms, + "attempts": report.attempts, + "reason": report.reason, + }) +} + +fn service_report_json(report: &ServiceReport) -> serde_json::Value { + serde_json::json!({ + "manager": report.manager.to_string(), + "action": report.action.as_str(), + "service_name": report.service_name, + "state": report.state, + "definition_path": report.definition_path, + "definition": report.definition, + "commands": report.commands, + "note": report.note, + }) +} + +fn print_service_report(report: &ServiceReport, json: bool) -> Result<()> { if json { println!( "{}", - serde_json::json!({ - "manager": report.manager.to_string(), - "action": format!("{:?}", report.action), - "service_name": report.service_name, - "state": report.state, - "definition_path": report.definition_path, - "definition": report.definition, - "commands": report.commands, - "note": report.note, - }) + serde_json::to_string_pretty(&service_report_json(report))? ); return Ok(()); } println!("service: {}", report.service_name); println!("manager: {}", report.manager); - println!("action: {:?}", report.action); + println!("action: {}", report.action.as_str()); if let Some(state) = &report.state { println!("state: {state}"); } - if let Some(path) = report.definition_path { + if let Some(path) = &report.definition_path { println!("definition: {}", path.display()); } if !report.commands.is_empty() { println!("commands:"); - for command in report.commands { - println!(" {}", shell_quote_command(&command)); + for command in &report.commands { + println!(" {}", shell_quote_command(command)); } } - if let Some(definition) = report.definition { + if let Some(definition) = &report.definition { println!("definition_body:"); print!("{definition}"); } @@ -4890,6 +5115,39 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> { Ok(()) } +fn print_service_logs_report(report: &ServiceLogsReport, json: bool) -> Result<()> { + if json { + println!( + "{}", + serde_json::to_string_pretty(&serde_json::json!({ + "type": "service-logs", + "manager": report.manager.to_string(), + "service_name": report.service_name, + "state": report.state.as_str(), + "sources": report.sources, + "command": report.command, + "lines": report.lines, + "note": report.note, + }))? + ); + return Ok(()); + } + println!("service: {}", report.service_name); + println!("manager: {}", report.manager); + println!("state: {}", report.state.as_str()); + for source in &report.sources { + println!("source: {}", source.display()); + } + if let Some(command) = &report.command { + println!("command: {}", shell_quote_command(command)); + } + for line in &report.lines { + println!("{line}"); + } + println!("note: {}", report.note); + Ok(()) +} + fn print_file_conflict(conflict: &geth_cas::FileConflict) { println!("root: {}", conflict.root); println!("resource: {}", conflict.resource); @@ -5010,7 +5268,7 @@ mod tests { #[test] fn common_daemon_lifecycle_commands_parse_directly() { - for command in ["install", "start", "stop", "status", "uninstall"] { + for command in ["install", "start", "stop", "status", "logs", "uninstall"] { let parsed = Cli::try_parse_from(["geth", "daemon", command]); assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}"); } @@ -5022,6 +5280,48 @@ mod tests { command: DaemonCommand::Run { ephemeral: true } } )); + assert!(Cli::try_parse_from(["geth", "daemon", "logs", "--lines", "0"]).is_err()); + } + + #[test] + fn service_install_copies_transient_binaries_unless_explicitly_overridden() { + assert!( + transient_executable_reason(std::path::Path::new("/work/geth/target/debug/geth")) + .is_some() + ); + assert!(transient_executable_reason(std::path::Path::new("/usr/local/bin/geth")).is_none()); + + let binary = tempfile::NamedTempFile::new().expect("temporary binary"); + std::fs::write(binary.path(), b"geth-test-binary").expect("write binary"); + let home = tempfile::tempdir().expect("temporary geth home"); + let paths = GethPaths::from_home(home.path()); + let (copied, source) = service_executable(&paths, Some(binary.path().to_path_buf()), false) + .expect("copy transient binary"); + assert_eq!( + copied, + paths + .home() + .join("bin") + .join(if cfg!(windows) { "geth.exe" } else { "geth" }) + ); + assert_eq!(source, Some(binary.path().canonicalize().expect("source"))); + assert_eq!( + std::fs::read(copied).expect("read copy"), + b"geth-test-binary" + ); + + std::fs::write(binary.path(), b"geth-updated-binary").expect("update source binary"); + let (copied, _) = service_executable(&paths, Some(binary.path().to_path_buf()), false) + .expect("replace copied binary"); + assert_eq!( + std::fs::read(copied).expect("read replacement"), + b"geth-updated-binary" + ); + + let (direct, source) = service_executable(&paths, Some(binary.path().to_path_buf()), true) + .expect("allow transient binary"); + assert_eq!(direct, binary.path().canonicalize().expect("source")); + assert!(source.is_none()); } #[test] diff --git a/crates/geth-node/src/service.rs b/crates/geth-node/src/service.rs index d30dd70..b578781 100644 --- a/crates/geth-node/src/service.rs +++ b/crates/geth-node/src/service.rs @@ -68,6 +68,51 @@ pub enum ServiceAction { Printed, } +impl ServiceAction { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Installed => "installed", + Self::Uninstalled => "uninstalled", + Self::Started => "started", + Self::Stopped => "stopped", + Self::Status => "status", + Self::Printed => "printed", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ServiceLogState { + Available, + Empty, + NotInstalled, + Unavailable, +} + +impl ServiceLogState { + #[must_use] + pub const fn as_str(&self) -> &'static str { + match self { + Self::Available => "available", + Self::Empty => "empty", + Self::NotInstalled => "not-installed", + Self::Unavailable => "unavailable", + } + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ServiceLogsReport { + pub manager: ServiceManager, + pub service_name: String, + pub state: ServiceLogState, + pub sources: Vec, + pub command: Option>, + pub lines: Vec, + pub note: String, +} + #[derive(Clone, Debug, PartialEq, Eq)] pub struct ServiceReport { pub manager: ServiceManager, @@ -261,6 +306,22 @@ pub fn status_user_service(manager: ServiceManager) -> Result Result { + let manager = manager.resolve()?; + let lines = lines.max(1); + match manager { + ServiceManager::SystemdUser => systemd_user_logs(manager, lines, follow), + ServiceManager::LaunchdUser => launchd_user_logs(paths, manager, lines, follow), + ServiceManager::WindowsTask => windows_task_logs(paths, manager, lines, follow), + ServiceManager::Auto => unreachable!("auto is resolved above"), + } +} + pub fn print_user_service( paths: &GethPaths, manager: ServiceManager, @@ -292,6 +353,310 @@ const SYSTEMD_UNIT: &str = "geth.service"; const LAUNCHD_LABEL: &str = "local.geth.daemon"; const WINDOWS_TASK_NAME: &str = "geth-daemon"; +fn systemd_user_logs( + manager: ServiceManager, + lines: usize, + follow: bool, +) -> Result { + if !systemd_unit_path()?.is_file() { + return Ok(log_report( + manager, + ServiceLogState::NotInstalled, + Vec::new(), + Some(vec![ + "journalctl".to_owned(), + "--user-unit".to_owned(), + SYSTEMD_UNIT.to_owned(), + ]), + Vec::new(), + "systemd user unit is not installed; run `geth daemon install` first", + )); + } + let mut args = vec![ + "--user-unit".to_owned(), + SYSTEMD_UNIT.to_owned(), + "--lines".to_owned(), + lines.to_string(), + "--no-pager".to_owned(), + ]; + if follow { + args.push("--follow".to_owned()); + } + command_logs( + manager, + "journalctl", + args, + follow, + Vec::new(), + "systemd user journal", + ) +} + +fn launchd_user_logs( + paths: &GethPaths, + manager: ServiceManager, + lines: usize, + follow: bool, +) -> Result { + if !launchd_plist_path()?.is_file() { + return Ok(log_report( + manager, + ServiceLogState::NotInstalled, + daemon_log_paths(paths), + None, + Vec::new(), + "launchd user agent is not installed; run `geth daemon install` first", + )); + } + file_service_logs(paths, manager, lines, follow, false) +} + +fn windows_task_logs( + paths: &GethPaths, + manager: ServiceManager, + lines: usize, + follow: bool, +) -> Result { + let status = match status_user_service(manager.clone()) { + Ok(status) => status, + Err(error) => { + return Ok(log_report( + manager, + ServiceLogState::Unavailable, + daemon_log_paths(paths), + Some(vec![ + "schtasks".to_owned(), + "/Query".to_owned(), + "/TN".to_owned(), + WINDOWS_TASK_NAME.to_owned(), + ]), + Vec::new(), + &format!("could not query the per-user scheduled task: {error}"), + )); + } + }; + if status.state.as_deref() == Some("not-installed") { + return Ok(log_report( + manager, + ServiceLogState::NotInstalled, + daemon_log_paths(paths), + None, + Vec::new(), + "Windows per-user task is not installed; run `geth daemon install` first", + )); + } + if status.state.as_deref() == Some("unknown") { + return Ok(log_report( + manager, + ServiceLogState::Unavailable, + daemon_log_paths(paths), + status.commands.into_iter().next(), + Vec::new(), + &status.note, + )); + } + file_service_logs(paths, manager, lines, follow, true) +} + +fn file_service_logs( + paths: &GethPaths, + manager: ServiceManager, + lines: usize, + follow: bool, + powershell: bool, +) -> Result { + let sources = daemon_log_paths(paths); + let existing = sources + .iter() + .filter(|path| path.is_file()) + .cloned() + .collect::>(); + if existing.is_empty() { + return Ok(log_report( + manager, + ServiceLogState::Empty, + sources, + None, + Vec::new(), + "service is installed but has not written any log entries", + )); + } + if follow { + if powershell { + let paths = existing + .iter() + .map(|path| format!("'{}'", path.display().to_string().replace('\'', "''"))) + .collect::>() + .join(","); + let script = format!("Get-Content -Path {paths} -Tail {lines} -Wait"); + return command_logs( + manager, + "powershell", + vec!["-NoProfile".to_owned(), "-Command".to_owned(), script], + true, + sources, + "Windows daemon log files", + ); + } + let mut args = vec!["-n".to_owned(), lines.to_string(), "-f".to_owned()]; + args.extend(existing.iter().map(|path| path.display().to_string())); + return command_logs( + manager, + "tail", + args, + true, + sources, + "launchd daemon log files", + ); + } + + let mut output = Vec::new(); + for path in &existing { + let bytes = std::fs::read(path)?; + let content = String::from_utf8_lossy(&bytes); + let file_lines = content.lines().collect::>(); + let start = file_lines.len().saturating_sub(lines); + let source = path + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("daemon.log"); + output.extend( + file_lines[start..] + .iter() + .map(|line| format!("{source}: {line}")), + ); + } + let (state, note) = if output.is_empty() { + ( + ServiceLogState::Empty, + "service log files exist but contain no entries", + ) + } else { + (ServiceLogState::Available, "read daemon log files") + }; + Ok(log_report(manager, state, sources, None, output, note)) +} + +fn command_logs( + manager: ServiceManager, + program: &str, + args: Vec, + follow: bool, + sources: Vec, + description: &str, +) -> Result { + let command = std::iter::once(program.to_owned()) + .chain(args.iter().cloned()) + .collect::>(); + if follow { + return match Command::new(program).args(&args).status() { + Ok(status) if status.success() => Ok(log_report( + manager, + ServiceLogState::Available, + sources, + Some(command), + Vec::new(), + &format!("finished following {description}"), + )), + Ok(status) => Ok(log_report( + manager, + ServiceLogState::Unavailable, + sources, + Some(command), + Vec::new(), + &format!("log command exited with status {status}"), + )), + Err(error) => Ok(log_report( + manager, + ServiceLogState::Unavailable, + sources, + Some(command), + Vec::new(), + &format!("could not run log command: {error}"), + )), + }; + } + + match Command::new(program).args(&args).output() { + Ok(output) if output.status.success() => { + let text = String::from_utf8_lossy(&output.stdout); + let lines = text + .lines() + .filter(|line| *line != "-- No entries --") + .map(ToOwned::to_owned) + .collect::>(); + let (state, note) = if lines.is_empty() { + ( + ServiceLogState::Empty, + format!("{description} contains no entries"), + ) + } else { + (ServiceLogState::Available, format!("read {description}")) + }; + Ok(log_report( + manager, + state, + sources, + Some(command), + lines, + ¬e, + )) + } + Ok(output) => { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); + Ok(log_report( + manager, + ServiceLogState::Unavailable, + sources, + Some(command), + Vec::new(), + &format!("could not read {description}: {stderr}"), + )) + } + Err(error) => Ok(log_report( + manager, + ServiceLogState::Unavailable, + sources, + Some(command), + Vec::new(), + &format!("could not run log command: {error}"), + )), + } +} + +fn daemon_log_paths(paths: &GethPaths) -> Vec { + vec![ + paths.run_dir().join("daemon.out.log"), + paths.run_dir().join("daemon.err.log"), + ] +} + +fn log_report( + manager: ServiceManager, + state: ServiceLogState, + sources: Vec, + command: Option>, + lines: Vec, + note: &str, +) -> ServiceLogsReport { + let service_name = match &manager { + ServiceManager::SystemdUser => SYSTEMD_UNIT, + ServiceManager::LaunchdUser => LAUNCHD_LABEL, + ServiceManager::WindowsTask => WINDOWS_TASK_NAME, + ServiceManager::Auto => "geth", + } + .to_owned(); + ServiceLogsReport { + manager, + service_name, + state, + sources, + command, + lines, + note: note.to_owned(), + } +} + fn install_systemd_user( paths: &GethPaths, executable: &Path, @@ -505,9 +870,11 @@ fn windows_task_command(paths: &GethPaths, executable: &Path) -> String { fn windows_task_run_command(paths: &GethPaths, executable: &Path) -> String { format!( - r#"cmd.exe /C "set GETH_HOME={}&& "{}" daemon run""#, + r#"cmd.exe /D /C "set "GETH_HOME={}" && "{}" daemon run 1>>"{}" 2>>"{}"""#, paths.home().display(), - executable.display() + executable.display(), + paths.run_dir().join("daemon.out.log").display(), + paths.run_dir().join("daemon.err.log").display() ) } @@ -691,9 +1058,38 @@ mod tests { fn windows_task_uses_current_user_logon_trigger() { let paths = GethPaths::from_home(r"C:\Users\Eric\AppData\Local\geth"); let command = windows_task_run_command(&paths, Path::new(r"C:\bin\geth.exe")); - assert!(command.contains("cmd.exe /C")); + assert!(command.contains("cmd.exe /D /C")); assert!(command.contains("GETH_HOME=")); assert!(command.contains("daemon run")); + assert!(command.contains("daemon.out.log")); + assert!(command.contains("daemon.err.log")); + } + + #[test] + fn file_service_logs_distinguish_empty_and_available_logs() { + let home = tempfile::tempdir().expect("tempdir"); + let paths = GethPaths::from_home(home.path()); + paths.ensure_base_dirs().expect("base dirs"); + + let empty = file_service_logs(&paths, ServiceManager::LaunchdUser, 20, false, false) + .expect("empty report"); + assert_eq!(empty.state, ServiceLogState::Empty); + + std::fs::write( + paths.run_dir().join("daemon.out.log"), + "old\nnewer\nnewest\n", + ) + .expect("write log"); + let available = file_service_logs(&paths, ServiceManager::LaunchdUser, 2, false, false) + .expect("available report"); + assert_eq!(available.state, ServiceLogState::Available); + assert_eq!( + available.lines, + vec![ + "daemon.out.log: newer".to_owned(), + "daemon.out.log: newest".to_owned() + ] + ); } #[test] diff --git a/docs/architecture.md b/docs/architecture.md index e7885c6..cab84e3 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -52,8 +52,14 @@ on macOS, and per-user scheduled tasks on Windows. Geth does not install itself as a privileged system service. The common service lifecycle is available directly as `geth daemon -install|start|stop|status|uninstall`; the nested service commands remain the -advanced and compatibility surface. `geth daemon run --ephemeral` creates a +install|start|stop|status|logs|uninstall`; the nested service commands remain +the advanced and compatibility surface. Direct installation waits for local +control readiness. A service executable resolved under a Cargo target or OS +temporary directory is copied atomically into the selected geth home's `bin/` +directory before the definition is written, unless the operator explicitly +allows the transient reference. Service log inspection reads the systemd user +journal on Linux and user-owned daemon log files on macOS and Windows. +`geth daemon run --ephemeral` creates a temporary home, reports how another CLI process can select it with `--home`, and removes it after a normal foreground shutdown. Ephemeral mode still starts the same daemon-owned Iroh endpoint and local control stack; it is not a second diff --git a/docs/roadmap.md b/docs/roadmap.md index 358cdfe..f793aec 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -88,14 +88,26 @@ For deployment-readiness work that cuts across feature areas, see - `[x]` Documentation distinguishes automated coverage from real-machine, hardware-key, relay, and privileged-interface dogfooding. -- `[ ]` Add unified service-log inspection. +- `[x]` Add unified service-log inspection. Acceptance criteria: - - `[ ]` One CLI command gives the platform-appropriate user-service log view + - `[x]` One CLI command gives the platform-appropriate user-service log view or an exact recovery command on Linux, macOS, and Windows. - - `[ ]` Log access remains user-scoped and does not require a system service. - - `[ ]` Human and JSON output distinguish unavailable logs, an uninstalled + - `[x]` Log access remains user-scoped and does not require a system service. + - `[x]` Human and JSON output distinguish unavailable logs, an uninstalled service, and an installed service with no log entries. +- `[x]` Verify background-service installation readiness and durability. + Acceptance criteria: + - `[x]` Direct `geth daemon install` waits for local control readiness by + default and reports a log-inspection recovery command on timeout. + - `[x]` Service executable paths are canonical regular files; Cargo-target + and temporary binaries are copied atomically into the selected geth home by + default rather than leaving a fragile service reference. + - `[x]` An explicit override supports intentional transient development + services, and advanced install can still omit immediate startup. + - `[x]` Tests cover transient path detection/copying, file-log states, and + user-scoped generated definitions. + - `[ ]` Publish copy-paste installation entrypoints for release artifacts. Acceptance criteria: - `[ ]` Linux, macOS, and Windows installation instructions verify artifact @@ -360,6 +372,9 @@ control, local CAS, service installation, and written architecture decisions. - Linux install targets a systemd user unit, not a system service. - macOS install targets a launchd user agent. - Windows install targets a per-user scheduled task. + - Direct install verifies local daemon readiness and exposes unified logs. + - Transient build artifacts are copied into the selected geth home before a + service definition references them unless explicitly overridden. - Tests verify generated definitions do not target privileged system services. - `[x]` GitHub CI, security, and release automation. diff --git a/docs/user-workflows.md b/docs/user-workflows.md index be056a5..831587d 100644 --- a/docs/user-workflows.md +++ b/docs/user-workflows.md @@ -39,11 +39,16 @@ geth status ``` `daemon install` initializes the selected home if needed, installs and enables -a service for the current user, and starts it. It never installs a system -service. Common lifecycle operations are direct: +a service for the current user, starts it, and waits for local control to become +ready. It never installs a system service. A binary launched from a Cargo target +or temporary directory is copied into the selected geth home first, preventing +later build cleanup from breaking the service. Common lifecycle operations are +direct: ```sh geth daemon status +geth daemon logs +geth daemon logs --follow geth daemon stop geth daemon start geth daemon uninstall @@ -53,6 +58,9 @@ The older `geth daemon service ...` family remains supported for scripts and advanced options. In particular, `geth daemon service install` installs without starting unless `--start` is supplied. +If readiness times out, inspect `geth daemon status` and `geth daemon logs`. +Use `daemon install --no-wait` only when another process owns readiness checks. + ### Keep state but run in the foreground User story: as a developer, I want persistent state and logs attached to my