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

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