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
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
`<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
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 <public-key> --signing-key <private-key> --node-name <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 <ms>`
- `geth doctor`

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]

View file

@ -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<PathBuf>,
pub command: Option<Vec<String>>,
pub lines: Vec<String>,
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<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(
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<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(
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]

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.
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

View file

@ -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.

View file

@ -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