use super::dirs::NO_HOST;
use super::{Doctor, Finding, Verdict, process};
use crate::service::{self, Platform, Report, path, unit, unit::Role};
use std::io::Read;
use std::os::unix::fs::{MetadataExt, OpenOptionsExt};
use std::path::{Path, PathBuf};
const NO_SERVICE: &str = "no service installed";
const SEVERAL: &str = "more than one service installed";
fn restart(doctor: &Doctor, role: Role) -> String {
service::start_command(doctor.cx, role)
}
fn canonical(path: &Path) -> PathBuf {
path.canonicalize().unwrap_or_else(|_| path.to_path_buf())
}
pub fn service(doctor: &Doctor) -> Finding {
let installed = doctor.cx.installed();
let mut verdict = Verdict::default();
match installed.as_slice() {
[] => verdict.warn(
"no service is installed: hennery runs only while started by hand",
"run `hennery service install` (with `--role host` on a machine that only hosts)",
),
[_] => {}
several => {
let names: Vec<&str> = several.iter().map(|r| r.name()).collect();
verdict.fail(
format!("{} roles are installed: {}", several.len(), names.join(", ")),
"keep one: `hennery service uninstall --role <role>` for the others",
);
}
}
for role in installed {
one_service(doctor, role, &mut verdict);
}
Finding::Checked(verdict.check(10, "service"))
}
fn one_service(doctor: &Doctor, role: Role, verdict: &mut Verdict) {
let cx = doctor.cx;
let reinstall = format!("run `hennery service install --role {role}` again");
let Some(argv) = service::read_command_line(cx, role).filter(|argv| !argv.is_empty()) else {
verdict.fail(
format!(
"the {role} service's file cannot be read ({})",
cx.service_file(role).display()
),
reinstall,
);
return;
};
let exe = PathBuf::from(&argv[0]);
if !exe.exists() {
verdict.fail(
format!("the {role} service runs {}, which is missing", exe.display()),
reinstall,
);
return;
}
if canonical(&exe) != canonical(&cx.exe) {
verdict.warn(
format!(
"the {role} service runs {}, not this binary ({})",
exe.display(),
cx.exe.display()
),
format!("run `hennery service install --role {role}` with the binary to use"),
);
}
if role == Role::Host
&& let Some(data) = service::data_dir_of(&argv)
{
match crate::revoked::current(&data) {
Ok(Some(revoked)) => {
let (why, fix) = revoked.said(&restart(doctor, role));
verdict.fail(format!("the {role} service's host was {why}"), fix);
return;
}
Ok(None) => {}
Err(err) => verdict.warn(
format!("whether the {role} service's host was revoked is unknown: {err:#}"),
format!(
"remove {} if it is not hennery's",
data.join(crate::revoked::FILE).display()
),
),
}
}
let managed = match service::managed(cx, role) {
Ok(Some(managed)) => managed,
Ok(None) => {
verdict.warn(
format!("`systemctl --user` cannot be run, so whether the {role} service runs is unknown"),
"run doctor in the login session the service runs in (not under `su` or `sudo -u`)",
);
return;
}
Err(err) => {
verdict.warn(
format!("the service manager cannot be asked about the {role} service: {err:#}"),
"run doctor in the login session the service runs in",
);
return;
}
};
if cx.platform == Platform::Linux && !service::linger_on(cx) {
verdict.warn(
"linger is off: the service stops when you log out and does not start at boot",
format!("run `loginctl enable-linger {}`", cx.user),
);
}
if !managed.running {
verdict.fail(
format!("the {role} service is not running ({})", managed.said),
format!("see `hennery service status` and the service's log, then {reinstall}"),
);
return;
}
verdict.ok(format!("the {role} service runs ({})", managed.said));
if let Some(pid) = managed.pid {
match process::runs(cx, pid, &exe) {
Some(true) => {}
Some(false) => verdict.warn(
format!(
"{} changed after the service started: pid {pid} still runs the old binary",
exe.display()
),
format!("restart it: `{}`", restart(doctor, role)),
),
None => verdict.ok(format!(
"whether pid {pid} runs {} as it is now is unknown",
exe.display()
)),
}
}
if role == Role::Up
&& let Some(data) = service::data_dir_of(&argv)
{
children(doctor, &data, managed.pid, verdict);
}
}
fn children(doctor: &Doctor, data: &Path, pid: Option<u32>, verdict: &mut Verdict) {
let state = match service::report(data, pid) {
Report::None | Report::Stale(_) => return,
Report::Unreadable(err) => {
verdict.fail(
format!("up's report of its children cannot be read: {err}"),
format!("restart the service: `{}`", restart(doctor, Role::Up)),
);
return;
}
Report::Current(state) => state,
};
for (name, child) in [("collector", &state.collector), ("host", &state.host)] {
let (what, healthy) = service::child_state(child);
if healthy {
continue;
}
let fix = match child.state {
crate::supervisor::ChildState::Revoked => {
let host = data.join("host");
format!(
"stop the service, remove {} and {}, and start it: up pairs its host again",
host.join(hennery_host::identity::KEY_FILE).display(),
host.join(hennery_host::identity::CONFIG_FILE).display()
)
}
_ => format!(
"fix the cause (the service's log), then restart the service: `{}`",
restart(doctor, Role::Up)
),
};
verdict.fail(format!("up's {name}: {what}"), fix);
}
}
fn installed_path(doctor: &Doctor, role: Role) -> Option<String> {
let cx = doctor.cx;
match cx.platform {
Platform::MacOs => unit::plist_path(&std::fs::read_to_string(cx.service_file(role)).ok()?),
Platform::Linux => unit::env_file_path(&std::fs::read_to_string(cx.env_file()).ok()?),
}
}
pub fn service_path_of(doctor: &Doctor) -> Option<String> {
match doctor.cx.installed()[..] {
[role] => installed_path(doctor, role),
_ => None,
}
}
fn installed_shell(doctor: &Doctor, role: Role) -> Option<String> {
let cx = doctor.cx;
match cx.platform {
Platform::MacOs => unit::plist_shell(&std::fs::read_to_string(cx.service_file(role)).ok()?),
Platform::Linux => unit::env_file_shell(&std::fs::read_to_string(cx.env_file()).ok()?),
}
}
pub fn service_shell_of(doctor: &Doctor) -> Option<String> {
match doctor.cx.installed()[..] {
[role] => installed_shell(doctor, role),
_ => None,
}
}
fn on_path(entries: &[&str], name: &str) -> bool {
use std::os::unix::fs::PermissionsExt;
entries.iter().any(|dir| {
std::fs::metadata(Path::new(dir).join(name)).is_ok_and(|m| m.is_file() && m.permissions().mode() & 0o111 != 0)
})
}
pub fn service_path(doctor: &Doctor) -> Finding {
let cx = doctor.cx;
let role = match cx.installed()[..] {
[] => {
return Finding::NotRun {
number: 5,
why: NO_SERVICE,
};
}
[role] => role,
_ => {
return Finding::NotRun {
number: 5,
why: SEVERAL,
};
}
};
let mut verdict = Verdict::default();
let reinstall = format!("run `hennery service install --role {role}` again");
let Some(installed) = installed_path(doctor, role) else {
verdict.fail(format!("the {role} service's PATH cannot be read"), reinstall);
return Finding::Checked(verdict.check(5, "service PATH"));
};
let entries: Vec<&str> = installed.split(':').filter(|e| !e.is_empty()).collect();
let mut missing = false;
let needed: &[&str] = if role == Role::Collector { &[] } else { &["sh", "git"] };
for &tool in needed {
if !on_path(&entries, tool) {
missing = true;
verdict.fail(
format!("{tool} is not on the service's PATH"),
format!("install {tool}, or put its directory on your login shell's PATH, then {reinstall}"),
);
}
}
if on_path(&entries, "rg") {
verdict.ok(if missing {
"rg is on the service's PATH"
} else {
"sh, git and rg are on the service's PATH"
});
} else {
verdict.ok("rg is not on the service's PATH (optional)");
}
let shaky = "fix that in your login shell's PATH, then run `hennery service install` again";
for entry in &entries {
if !Path::new(entry).is_dir() {
verdict.warn(format!("{entry} on the service's PATH does not exist"), shaky);
}
}
let macos = cx.platform == Platform::MacOs;
let given = std::collections::BTreeMap::from([("PATH".to_string(), installed.clone())]);
if let Ok(read) = path::service_path(&given, &cx.home, macos) {
for note in read.notes {
verdict.warn(format!("the service's PATH: {note}"), shaky);
}
}
let recorded = installed_shell(doctor, role);
let runnable = recorded
.as_deref()
.map(PathBuf::from)
.filter(|shell| shell.is_absolute());
if let Some(relative) = recorded.as_deref().filter(|_| runnable.is_none()) {
verdict.warn(
format!("the service's SHELL ({relative}) is not an absolute path"),
reinstall.clone(),
);
}
let login = runnable.clone().unwrap_or_else(|| cx.shell.clone());
let shell = login.display();
let again = match &runnable {
Some(runnable) if *runnable != cx.shell => format!(
"the service's PATH came from {}, and your account's shell is {}: run `hennery service install --role {role} --shell {}` again to keep the first, or without `--shell` for the second",
runnable.display(),
cx.shell.display(),
runnable.display()
),
Some(_) => reinstall.clone(),
None => format!("{reinstall} (with `--shell` if it was installed with another shell)"),
};
let env = path::shell_environment(&cx.env, &login, macos);
let now = path::login_environment(&login, &env, path::CAPTURE_TIMEOUT)
.and_then(|captured| path::service_path(&captured, &cx.home, macos));
match now {
Ok(now) if now.path != installed => verdict.warn(
format!("your login shell ({shell}) now gives another PATH than the service has"),
again,
),
Ok(_) => verdict.ok(format!("your login shell ({shell}) gives the same PATH")),
Err(err) => verdict.warn(
format!("your login shell's ({shell}) PATH could not be captured: {err:#}"),
format!("check that `{shell} -l -i -c env` runs, or {reinstall} with another `--shell`"),
),
}
Finding::Checked(verdict.check(5, "service PATH"))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Holder {
Nobody,
Pid(u32),
Unknown(String),
}
const MAX_LOCK_TEXT: u64 = 32;
pub fn holder(doctor: &Doctor, lock: &Path) -> Holder {
match std::fs::symlink_metadata(lock) {
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Holder::Nobody,
Err(err) => return Holder::Unknown(format!("{}: {err}", lock.display())),
Ok(meta) if !meta.is_file() => return Holder::Unknown(format!("{} is not a regular file", lock.display())),
Ok(_) => {}
}
if let Some(pid) = process::flock_holder(doctor.cx, lock) {
return Holder::Pid(pid);
}
let file = std::fs::OpenOptions::new()
.read(true)
.custom_flags(libc::O_NONBLOCK)
.open(lock);
let mut file = match file {
Ok(file) => file,
Err(err) => return Holder::Unknown(format!("{}: {err}", lock.display())),
};
let mut text = String::new();
let _ = (&mut file).take(MAX_LOCK_TEXT).read_to_string(&mut text);
let pid = text.trim().parse::<u32>().ok();
if pid.is_some_and(|pid| !service::alive(pid)) {
return Holder::Nobody;
}
use std::os::fd::AsRawFd;
if unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } == 0 {
return Holder::Nobody;
}
match std::io::Error::last_os_error() {
err if err.raw_os_error() == Some(libc::EWOULDBLOCK) => match pid {
Some(pid) => Holder::Pid(pid),
None => Holder::Unknown(format!(
"{} is held by a process that has not written its pid",
lock.display()
)),
},
err => Holder::Unknown(format!("{}: {err}", lock.display())),
}
}
fn service_of(doctor: &Doctor, host: &Path) -> Result<Option<(Role, Option<u32>)>, &'static str> {
let cx = doctor.cx;
let role = match cx.installed()[..] {
[] => return Ok(None),
[role] => role,
_ => return Err("more than one service is installed"),
};
let Some(data) = service::read_command_line(cx, role).and_then(|argv| service::data_dir_of(&argv)) else {
return Err("the service's command line cannot be read");
};
let served = match role {
Role::Up => data.join("host"),
Role::Host => data.clone(),
Role::Collector => return Ok(None),
};
if canonical(&served) != canonical(host) {
return Ok(None);
}
let mut pid = service::managed(cx, role).ok().flatten().and_then(|m| m.pid);
if role == Role::Up
&& pid.is_none()
&& let Report::Current(state) = service::report(&data, None)
{
pid = Some(state.pid);
}
Ok(Some((role, pid)))
}
fn up_by_hand(host: &Path) -> Option<u32> {
match service::report(host.parent()?, None) {
Report::Current(state) => Some(state.pid),
_ => None,
}
}
pub fn host_directory(doctor: &Doctor) -> Finding {
let Some(host) = &doctor.dirs.host else {
return Finding::NotRun {
number: 14,
why: NO_HOST,
};
};
let mut verdict = Verdict::default();
if std::fs::metadata(host).is_ok_and(|m| m.mode() & 0o077 != 0) {
verdict.warn(
format!("{} can be read by other users", host.display()),
format!("run `chmod 700 {}`", host.display()),
);
}
let key = host.join(hennery_host::identity::KEY_FILE);
if host
.join(hennery_host::identity::CONFIG_FILE)
.symlink_metadata()
.is_ok()
|| hennery_host::enrollment::has_artifacts(host)
{
let integrity = match hennery_host::enrollment::inspect(host) {
Ok(hennery_host::enrollment::EnrollmentState::Uncertain(attempt)) => {
Err(anyhow::anyhow!(attempt.diagnostic()))
}
Ok(hennery_host::enrollment::EnrollmentState::Confirmed(_)) => {
Err(anyhow::anyhow!("confirmed enrollment needs durable roll-forward"))
}
Ok(hennery_host::enrollment::EnrollmentState::Clear) => hennery_host::identity::inspect_transport(host),
Err(error) => Err(error),
};
match integrity {
Ok(()) => {}
Err(err)
if matches!(
hennery_host::enrollment::inspect(host),
Ok(hennery_host::enrollment::EnrollmentState::Confirmed(_))
) || (matches!(
hennery_host::enrollment::inspect(host),
Ok(hennery_host::enrollment::EnrollmentState::Clear)
) && host.join("host.toml.pending").exists()) =>
{
verdict.warn(err.to_string(), "finish confirmed roll-forward with host run")
}
Err(err) => verdict.fail(
err.to_string(),
"repair transport configuration integrity or explicitly recover enrollment; preserve artifacts",
),
}
}
if std::fs::metadata(&key).is_ok_and(|m| m.mode() & 0o077 != 0) {
verdict.warn(
format!("{} can be read by other users", key.display()),
format!(
"run `chmod 600 {}`, and pair again if it may have leaked",
key.display()
),
);
}
let lock = host.join(crate::lock::HOST_LOCK);
match holder(doctor, &lock) {
Holder::Nobody => verdict.ok("no host runs on it"),
Holder::Unknown(why) => verdict.warn(
format!("whether a host runs on it is unknown: {why}"),
"see `hennery service status`, and the processes named hennery",
),
Holder::Pid(pid) => match service_of(doctor, host) {
Err(why) => verdict.warn(
format!("pid {pid} serves it, and which service should is unknown: {why}"),
"see check 10, and keep one service with a command line hennery wrote",
),
Ok(None) if up_by_hand(host).is_some_and(|up| process::parent(doctor.cx, pid) == Some(up)) => {
verdict.ok(format!("up's host (pid {pid}) serves it"))
}
Ok(None) => verdict.ok(format!("pid {pid} serves it: a host started by hand")),
Ok(Some((Role::Host, Some(service)))) if service == pid => {
verdict.ok(format!("the host service (pid {pid}) serves it"))
}
Ok(Some((Role::Up, Some(up)))) if process::parent(doctor.cx, pid) == Some(up) => {
verdict.ok(format!("up's host (pid {pid}) serves it"))
}
Ok(Some((role, None))) => verdict.warn(
format!("pid {pid} serves it; whether it is the {role} service's host is unknown"),
"see check 10: the service manager could not say which process it runs",
),
Ok(Some((Role::Up, Some(_)))) if process::parent(doctor.cx, pid).is_none() => verdict.warn(
format!("pid {pid} serves it; whether it is up's host is unknown: its parent cannot be read"),
"check by hand that its parent is the up service's process",
),
Ok(Some((role, _))) => verdict.warn(
format!("pid {pid}, not the {role} service's host, serves it: the service's host cannot start"),
format!("stop pid {pid} (`kill {pid}`), and let the service run its host"),
),
},
}
Finding::Checked(verdict.check(14, "host data directory"))
}