Harden daemon service installation

This commit is contained in:
Eric Wendland 2026-07-18 16:31:59 +02:00
commit 678865bfdc
6 changed files with 784 additions and 46 deletions

View file

@ -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 Ctrl-C stops the daemon and removes its temporary state. For a persistent
background node, run `geth daemon install`; this initializes local state, 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 enrolling other machines, use `geth guide owner-setup`; it records an
OpenSSH admin public key as the trust anchor and signs the initial keychain OpenSSH admin public key as the trust anchor and signs the initial keychain
statements without copying the private key into geth state. statements without copying the private key into geth state.
@ -92,6 +93,7 @@ The daemon can also install itself as a user service:
```sh ```sh
geth daemon install geth daemon install
geth daemon status geth daemon status
geth daemon logs
geth daemon uninstall 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 services, not system services. The longer `geth daemon service ...` family is
retained for compatibility and advanced options. 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
`<geth-home>/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 ## Transport And SSH
All remote node-to-node geth communication is designed to happen over Iroh only. 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`
- `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>` - `geth init --admin-key <public-key> --signing-key <private-key> --node-name <name>`
- `geth daemon run [--ephemeral]` - `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 daemon service install|uninstall|start|stop|status|print`
- `geth config path|show|validate|set`
- `geth status` - `geth status`
- `geth wait daemon|peer|sync --timeout-ms <ms>` - `geth wait daemon|peer|sync --timeout-ms <ms>`
- `geth doctor` - `geth doctor`

View file

@ -4,7 +4,9 @@ use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand, ValueEnum};
use clap_complete::{Shell, generate}; use clap_complete::{Shell, generate};
use geth_config::{ConfigKey, GethConfig, GethPaths}; use geth_config::{ConfigKey, GethConfig, GethPaths};
use geth_control::{ControlRequest, ControlResponse, SyncStreamStatus}; 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::io::{Read, Write, stdout};
use std::path::PathBuf; use std::path::PathBuf;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -24,6 +26,7 @@ Use `geth <command> --help` for details and `geth guide` for complete workflows.
const DAEMON_AFTER_HELP: &str = r#"Examples: const DAEMON_AFTER_HELP: &str = r#"Examples:
geth daemon install # persistent user service; starts immediately geth daemon install # persistent user service; starts immediately
geth daemon status geth daemon status
geth daemon logs
geth daemon stop geth daemon stop
geth daemon start geth daemon start
geth daemon uninstall geth daemon uninstall
@ -241,6 +244,8 @@ const GUIDE_SERVICE: &str = r#"Install geth as a user service:
geth daemon install geth daemon install
geth daemon status geth daemon status
geth daemon logs
geth daemon logs --follow
geth daemon stop geth daemon stop
geth daemon start geth daemon start
geth daemon uninstall geth daemon uninstall
@ -250,6 +255,12 @@ Service installation targets user service managers, not system services:
macOS: launchd user agent macOS: launchd user agent
Windows: current-user scheduled task 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 `<geth-home>/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: Preview definitions without installing:
geth daemon service print geth daemon service print
@ -590,6 +601,22 @@ pub enum DaemonCommand {
manager: String, manager: String,
#[arg(long, help = "Executable path stored in the service definition")] #[arg(long, help = "Executable path stored in the service definition")]
bin: Option<PathBuf>, bin: Option<PathBuf>,
#[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 the installed user service
Start { Start {
@ -606,6 +633,20 @@ pub enum DaemonCommand {
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
manager: String, 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 /// Stop and remove the installed user service
Uninstall { Uninstall {
#[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)] #[arg(long, default_value = "auto", help = SERVICE_MANAGER_HELP)]
@ -628,6 +669,11 @@ pub enum ServiceCommand {
bin: Option<PathBuf>, bin: Option<PathBuf>,
#[arg(long, help = "Start the service immediately after installation")] #[arg(long, help = "Start the service immediately after installation")]
start: bool, 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 /// Stop and remove the user service
Uninstall { Uninstall {
@ -1765,7 +1811,14 @@ async fn run_inner(cli: Cli) -> Result<()> {
.context("run geth daemon")?; .context("run geth daemon")?;
} }
Command::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( let report = run_service_command(
&paths, &paths,
@ -1773,45 +1826,89 @@ async fn run_inner(cli: Cli) -> Result<()> {
manager, manager,
bin, bin,
start: true, start: true,
allow_transient_binary,
}, },
) )
.context("install and start geth user service")?; .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::Daemon {
command: DaemonCommand::Start { manager }, command: DaemonCommand::Start { manager },
} => { } => {
let report = run_service_command(&paths, ServiceCommand::Start { manager }) let report = run_service_command(&paths, ServiceCommand::Start { manager })
.context("start geth user service; install it first with `geth daemon install`")?; .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::Daemon {
command: DaemonCommand::Stop { manager }, command: DaemonCommand::Stop { manager },
} => { } => {
let report = run_service_command(&paths, ServiceCommand::Stop { manager }) let report = run_service_command(&paths, ServiceCommand::Stop { manager })
.context("stop geth user service")?; .context("stop geth user service")?;
print_service_report(report, cli.json || cli.jsonl)?; print_service_report(&report, cli.json || cli.jsonl)?;
} }
Command::Daemon { Command::Daemon {
command: DaemonCommand::Status { manager }, command: DaemonCommand::Status { manager },
} => { } => {
let report = run_service_command(&paths, ServiceCommand::Status { manager }) let report = run_service_command(&paths, ServiceCommand::Status { manager })
.context("query geth user service; install it with `geth daemon install`")?; .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::<ServiceManager>()?,
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::Daemon {
command: DaemonCommand::Uninstall { manager }, command: DaemonCommand::Uninstall { manager },
} => { } => {
let report = run_service_command(&paths, ServiceCommand::Uninstall { manager }) let report = run_service_command(&paths, ServiceCommand::Uninstall { manager })
.context("uninstall geth user service")?; .context("uninstall geth user service")?;
print_service_report(report, cli.json || cli.jsonl)?; print_service_report(&report, cli.json || cli.jsonl)?;
} }
Command::Daemon { Command::Daemon {
command: DaemonCommand::Service { command }, command: DaemonCommand::Service { command },
} => { } => {
let report = let report =
run_service_command(&paths, command).context("manage geth user service")?; 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 } => { Command::Wait { command } => {
let report = run_wait_command(&paths, command).await?; let report = run_wait_command(&paths, command).await?;
@ -3162,18 +3259,27 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
manager, manager,
bin, bin,
start, start,
allow_transient_binary,
} => { } => {
geth_node::init_node(paths).context("initialize geth home before service install")?;
let manager = manager.parse::<ServiceManager>()?; let manager = manager.parse::<ServiceManager>()?;
let executable = service_executable(bin)?; geth_node::init_node(paths).context("initialize geth home before service install")?;
geth_node::service::install_user_service( let (executable, copied_from) = service_executable(paths, bin, allow_transient_binary)?;
let mut report = geth_node::service::install_user_service(
paths, paths,
ServiceInstallOptions { ServiceInstallOptions {
manager, manager,
executable, executable,
start, 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 } => { ServiceCommand::Uninstall { manager } => {
geth_node::service::uninstall_user_service(manager.parse::<ServiceManager>()?)? geth_node::service::uninstall_user_service(manager.parse::<ServiceManager>()?)?
@ -3188,7 +3294,10 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)? geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)?
} }
ServiceCommand::Print { manager, bin } => { 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( geth_node::service::print_user_service(
paths, paths,
manager.parse::<ServiceManager>()?, manager.parse::<ServiceManager>()?,
@ -3198,10 +3307,82 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
}) })
} }
fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> { fn parse_log_line_count(value: &str) -> std::result::Result<usize, String> {
bin.map(Ok) value
.parse::<usize>()
.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<PathBuf>,
allow_transient: bool,
) -> Result<(PathBuf, Option<PathBuf>)> {
let executable = bin
.map(Ok)
.unwrap_or_else(std::env::current_exe) .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::<Vec<_>>();
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) { fn print_keychain_sigchain_report(report: &geth_keychain::KeychainSigchainReport) {
@ -4849,40 +5030,84 @@ fn print_response(response: ControlResponse, json: bool) -> Result<()> {
Ok(()) 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 { if json {
println!( println!(
"{}", "{}",
serde_json::json!({ serde_json::to_string_pretty(&service_report_json(report))?
"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,
})
); );
return Ok(()); return Ok(());
} }
println!("service: {}", report.service_name); println!("service: {}", report.service_name);
println!("manager: {}", report.manager); println!("manager: {}", report.manager);
println!("action: {:?}", report.action); println!("action: {}", report.action.as_str());
if let Some(state) = &report.state { if let Some(state) = &report.state {
println!("state: {state}"); println!("state: {state}");
} }
if let Some(path) = report.definition_path { if let Some(path) = &report.definition_path {
println!("definition: {}", path.display()); println!("definition: {}", path.display());
} }
if !report.commands.is_empty() { if !report.commands.is_empty() {
println!("commands:"); println!("commands:");
for command in report.commands { for command in &report.commands {
println!(" {}", shell_quote_command(&command)); println!(" {}", shell_quote_command(command));
} }
} }
if let Some(definition) = report.definition { if let Some(definition) = &report.definition {
println!("definition_body:"); println!("definition_body:");
print!("{definition}"); print!("{definition}");
} }
@ -4890,6 +5115,39 @@ fn print_service_report(report: ServiceReport, json: bool) -> Result<()> {
Ok(()) 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) { fn print_file_conflict(conflict: &geth_cas::FileConflict) {
println!("root: {}", conflict.root); println!("root: {}", conflict.root);
println!("resource: {}", conflict.resource); println!("resource: {}", conflict.resource);
@ -5010,7 +5268,7 @@ mod tests {
#[test] #[test]
fn common_daemon_lifecycle_commands_parse_directly() { 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]); let parsed = Cli::try_parse_from(["geth", "daemon", command]);
assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}"); assert!(parsed.is_ok(), "daemon {command} should parse: {parsed:?}");
} }
@ -5022,6 +5280,48 @@ mod tests {
command: DaemonCommand::Run { ephemeral: true } 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] #[test]

View file

@ -68,6 +68,51 @@ pub enum ServiceAction {
Printed, 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<PathBuf>,
pub command: Option<Vec<String>>,
pub lines: Vec<String>,
pub note: String,
}
#[derive(Clone, Debug, PartialEq, Eq)] #[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServiceReport { pub struct ServiceReport {
pub manager: ServiceManager, pub manager: ServiceManager,
@ -261,6 +306,22 @@ pub fn status_user_service(manager: ServiceManager) -> Result<ServiceReport, Ser
} }
} }
pub fn logs_user_service(
paths: &GethPaths,
manager: ServiceManager,
lines: usize,
follow: bool,
) -> Result<ServiceLogsReport, ServiceError> {
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( pub fn print_user_service(
paths: &GethPaths, paths: &GethPaths,
manager: ServiceManager, manager: ServiceManager,
@ -292,6 +353,310 @@ const SYSTEMD_UNIT: &str = "geth.service";
const LAUNCHD_LABEL: &str = "local.geth.daemon"; const LAUNCHD_LABEL: &str = "local.geth.daemon";
const WINDOWS_TASK_NAME: &str = "geth-daemon"; const WINDOWS_TASK_NAME: &str = "geth-daemon";
fn systemd_user_logs(
manager: ServiceManager,
lines: usize,
follow: bool,
) -> Result<ServiceLogsReport, ServiceError> {
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<ServiceLogsReport, ServiceError> {
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<ServiceLogsReport, ServiceError> {
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<ServiceLogsReport, ServiceError> {
let sources = daemon_log_paths(paths);
let existing = sources
.iter()
.filter(|path| path.is_file())
.cloned()
.collect::<Vec<_>>();
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::<Vec<_>>()
.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::<Vec<_>>();
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<String>,
follow: bool,
sources: Vec<PathBuf>,
description: &str,
) -> Result<ServiceLogsReport, ServiceError> {
let command = std::iter::once(program.to_owned())
.chain(args.iter().cloned())
.collect::<Vec<_>>();
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::<Vec<_>>();
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,
&note,
))
}
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<PathBuf> {
vec![
paths.run_dir().join("daemon.out.log"),
paths.run_dir().join("daemon.err.log"),
]
}
fn log_report(
manager: ServiceManager,
state: ServiceLogState,
sources: Vec<PathBuf>,
command: Option<Vec<String>>,
lines: Vec<String>,
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( fn install_systemd_user(
paths: &GethPaths, paths: &GethPaths,
executable: &Path, 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 { fn windows_task_run_command(paths: &GethPaths, executable: &Path) -> String {
format!( 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(), 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() { fn windows_task_uses_current_user_logon_trigger() {
let paths = GethPaths::from_home(r"C:\Users\Eric\AppData\Local\geth"); 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")); 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("GETH_HOME="));
assert!(command.contains("daemon run")); 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] #[test]

View file

@ -52,8 +52,14 @@ on macOS, and per-user scheduled tasks on Windows. Geth does not install itself
as a privileged system service. as a privileged system service.
The common service lifecycle is available directly as `geth daemon The common service lifecycle is available directly as `geth daemon
install|start|stop|status|uninstall`; the nested service commands remain the install|start|stop|status|logs|uninstall`; the nested service commands remain
advanced and compatibility surface. `geth daemon run --ephemeral` creates a 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 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 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 same daemon-owned Iroh endpoint and local control stack; it is not a second

View file

@ -88,14 +88,26 @@ For deployment-readiness work that cuts across feature areas, see
- `[x]` Documentation distinguishes automated coverage from real-machine, - `[x]` Documentation distinguishes automated coverage from real-machine,
hardware-key, relay, and privileged-interface dogfooding. hardware-key, relay, and privileged-interface dogfooding.
- `[ ]` Add unified service-log inspection. - `[x]` Add unified service-log inspection.
Acceptance criteria: 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. or an exact recovery command on Linux, macOS, and Windows.
- `[ ]` Log access remains user-scoped and does not require a system service. - `[x]` Log access remains user-scoped and does not require a system service.
- `[ ]` Human and JSON output distinguish unavailable logs, an uninstalled - `[x]` Human and JSON output distinguish unavailable logs, an uninstalled
service, and an installed service with no log entries. 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. - `[ ]` Publish copy-paste installation entrypoints for release artifacts.
Acceptance criteria: Acceptance criteria:
- `[ ]` Linux, macOS, and Windows installation instructions verify artifact - `[ ]` 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. - Linux install targets a systemd user unit, not a system service.
- macOS install targets a launchd user agent. - macOS install targets a launchd user agent.
- Windows install targets a per-user scheduled task. - 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. - Tests verify generated definitions do not target privileged system services.
- `[x]` GitHub CI, security, and release automation. - `[x]` GitHub CI, security, and release automation.

View file

@ -39,11 +39,16 @@ geth status
``` ```
`daemon install` initializes the selected home if needed, installs and enables `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 a service for the current user, starts it, and waits for local control to become
service. Common lifecycle operations are direct: 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 ```sh
geth daemon status geth daemon status
geth daemon logs
geth daemon logs --follow
geth daemon stop geth daemon stop
geth daemon start geth daemon start
geth daemon uninstall 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 advanced options. In particular, `geth daemon service install` installs without
starting unless `--start` is supplied. 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 ### Keep state but run in the foreground
User story: as a developer, I want persistent state and logs attached to my User story: as a developer, I want persistent state and logs attached to my