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

@ -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 <command> --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 `<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:
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<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 {
@ -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<PathBuf>,
#[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::<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: 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<Ser
manager,
bin,
start,
allow_transient_binary,
} => {
geth_node::init_node(paths).context("initialize geth home before service install")?;
let manager = manager.parse::<ServiceManager>()?;
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::<ServiceManager>()?)?
@ -3188,7 +3294,10 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
geth_node::service::status_user_service(manager.parse::<ServiceManager>()?)?
}
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::<ServiceManager>()?,
@ -3198,10 +3307,82 @@ fn run_service_command(paths: &GethPaths, command: ServiceCommand) -> Result<Ser
})
}
fn service_executable(bin: Option<PathBuf>) -> Result<PathBuf> {
bin.map(Ok)
fn parse_log_line_count(value: &str) -> std::result::Result<usize, String> {
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)
.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) {
@ -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]