Harden daemon service installation
This commit is contained in:
parent
443602a30b
commit
678865bfdc
6 changed files with 784 additions and 46 deletions
|
|
@ -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,
|
||||
¬e,
|
||||
))
|
||||
}
|
||||
Ok(output) => {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned();
|
||||
Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
sources,
|
||||
Some(command),
|
||||
Vec::new(),
|
||||
&format!("could not read {description}: {stderr}"),
|
||||
))
|
||||
}
|
||||
Err(error) => Ok(log_report(
|
||||
manager,
|
||||
ServiceLogState::Unavailable,
|
||||
sources,
|
||||
Some(command),
|
||||
Vec::new(),
|
||||
&format!("could not run log command: {error}"),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn daemon_log_paths(paths: &GethPaths) -> Vec<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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue