Add SSH cert flows and user service installer

This commit is contained in:
Eric Wendland 2026-05-16 00:17:08 +02:00
commit f302342b1c
21 changed files with 2158 additions and 14 deletions

View file

@ -16,5 +16,6 @@ geth-config = { path = "../geth-config" }
geth-control = { path = "../geth-control" }
geth-crypto = { path = "../geth-crypto" }
geth-resource = { path = "../geth-resource" }
geth-ssh-identity = { path = "../geth-ssh-identity" }
geth-store = { path = "../geth-store" }
geth-types = { path = "../geth-types" }

View file

@ -1,3 +1,5 @@
pub mod service;
use geth_auth::AuthExplanation;
use geth_cas::{LocalCas, hash_path};
use geth_config::GethPaths;
@ -7,8 +9,17 @@ use geth_control::{
};
use geth_crypto::AgentKey;
use geth_resource::ResourceDescriptor;
use geth_store::{Store, StoredResource};
use geth_types::{ResourceId, ResourceKind, ResourceName};
use geth_ssh_identity::{
SshCertApproval, SshCertKind, SshCertRequest, SshCertRequestStatus, SshCertificateRecord,
SshRevocationEntry, SshRevocationKind, build_ssh_cert_sign_command, cert_request_id,
certificate_id, revocation_id, ssh_public_key_fingerprint,
};
use geth_store::{
Store, StoredResource, StoredSshCertRequest, StoredSshCertificate, StoredSshRevocation,
};
use geth_types::{
NodeId, ResourceId, ResourceKind, ResourceName, SshCertId, SshCertRequestId, UnixMillis,
};
use std::path::Path;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::{UnixListener, UnixStream};
@ -25,10 +36,24 @@ pub enum NodeError {
Cas(#[from] geth_cas::CasError),
#[error("control error: {0}")]
Control(#[from] geth_control::ControlError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("invalid resource kind: {0}")]
InvalidResourceKind(String),
#[error("invalid ssh certificate kind: {0}")]
InvalidSshCertKind(String),
#[error("invalid ssh certificate request status: {0}")]
InvalidSshCertStatus(String),
#[error("invalid ssh revocation kind: {0}")]
InvalidSshRevocationKind(String),
#[error("ssh certificate request not found: {0}")]
SshCertRequestNotFound(String),
#[error("ssh certificate request must include at least one principal")]
MissingSshCertPrincipal,
#[error("ssh certificate flow error: {0}")]
SshCertFlow(#[from] geth_ssh_identity::SshCertFlowError),
}
#[derive(Clone, Debug)]
@ -221,6 +246,176 @@ pub fn handle_request(
} => Ok(ControlResponse::AuthExplain(AuthExplanation::stub(
subject, resource, capability,
))),
ControlRequest::SshCertRequest {
public_key_path,
cert_kind,
principals,
requested_validity,
renewal_of,
reason,
} => {
if principals.is_empty() {
return Err(NodeError::MissingSshCertPrincipal);
}
let cert_kind = cert_kind
.parse::<SshCertKind>()
.map_err(|_| NodeError::InvalidSshCertKind(cert_kind.clone()))?;
let public_key = std::fs::read_to_string(&public_key_path)?;
let created_at = UnixMillis(geth_store::now_ms());
let request = SshCertRequest {
id: cert_request_id(
&NodeId::new(node.node_id.clone()),
&public_key,
&principals,
created_at,
),
requester_node: NodeId::new(node.node_id.clone()),
public_key_fingerprint: ssh_public_key_fingerprint(&public_key),
public_key,
cert_kind,
principals,
requested_validity,
renewal_of: renewal_of.map(SshCertId::new),
reason,
status: SshCertRequestStatus::Pending,
created_at,
};
store.insert_ssh_cert_request(&stored_from_ssh_cert_request(&request))?;
Ok(ControlResponse::SshCertRequested { request })
}
ControlRequest::SshCertRequests => Ok(ControlResponse::SshCertRequests {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::SshCertApprove {
request_id,
ca_key_path,
valid_for,
serial,
out,
} => {
let stored = store
.get_ssh_cert_request(&request_id)?
.ok_or_else(|| NodeError::SshCertRequestNotFound(request_id.clone()))?;
let mut request = ssh_cert_request_from_stored(stored)?;
request.status = SshCertRequestStatus::Approved;
store.update_ssh_cert_request_status(request.id.as_str(), request.status.as_str())?;
let public_key_path = out.clone().unwrap_or_else(|| {
node.paths
.home()
.join("ssh-cert-requests")
.join(format!("{}.pub", request.id))
});
if let Some(parent) = public_key_path.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(&public_key_path, &request.public_key)?;
let valid_for = valid_for
.or_else(|| request.requested_validity.clone())
.unwrap_or_else(|| "+52w".to_owned());
let signing_command = build_ssh_cert_sign_command(
&request,
&ca_key_path,
&public_key_path,
&valid_for,
serial,
)?;
let approval = SshCertApproval {
request_id: request.id,
approved_by_node: NodeId::new(node.node_id.clone()),
ca_key_path: ca_key_path.display().to_string(),
key_id: request_id,
valid_for,
serial,
output_path: Some(expected_openssh_cert_path(&public_key_path)),
signing_command,
note: "request approved; run the signing command on the CA/YubiKey machine, then import the resulting -cert.pub file".to_owned(),
};
Ok(ControlResponse::SshCertApproved { approval })
}
ControlRequest::SshCertImport {
request_id,
cert_path,
} => {
let certificate = std::fs::read_to_string(&cert_path)?;
let record = SshCertificateRecord {
id: certificate_id(&certificate),
request_id: SshCertRequestId::new(request_id.clone()),
certificate_fingerprint: ssh_public_key_fingerprint(&certificate),
certificate,
imported_at: UnixMillis(geth_store::now_ms()),
};
store.insert_ssh_certificate(&stored_from_ssh_certificate(&record))?;
store.update_ssh_cert_request_status(
&request_id,
SshCertRequestStatus::Signed.as_str(),
)?;
Ok(ControlResponse::SshCertImported {
certificate: record,
})
}
ControlRequest::SshCertList => Ok(ControlResponse::SshCertList {
requests: store
.list_ssh_cert_requests()?
.into_iter()
.map(ssh_cert_request_from_stored)
.collect::<Result<Vec<_>, _>>()?,
certificates: store
.list_ssh_certificates()?
.into_iter()
.map(ssh_certificate_from_stored)
.collect(),
}),
ControlRequest::SshRevocationAdd {
kind,
target,
reason,
} => {
let kind = kind
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(kind.clone()))?;
let created_at = UnixMillis(geth_store::now_ms());
let revocation = SshRevocationEntry {
id: revocation_id(&kind, &target, created_at),
kind,
target,
reason,
created_at,
published: true,
};
store.insert_ssh_revocation(&stored_from_ssh_revocation(&revocation))?;
Ok(ControlResponse::SshRevocationAdded { revocation })
}
ControlRequest::SshRevocationList => Ok(ControlResponse::SshRevocationList {
revocations: store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?,
}),
ControlRequest::SshRevocationExport { out } => {
let revocations = store
.list_ssh_revocations()?
.into_iter()
.map(ssh_revocation_from_stored)
.collect::<Result<Vec<_>, _>>()?;
if let Some(parent) = out.parent() {
std::fs::create_dir_all(parent)?;
}
let mut body = String::new();
for revocation in &revocations {
body.push_str(&serde_json::to_string(revocation)?);
body.push('\n');
}
std::fs::write(&out, body)?;
Ok(ControlResponse::SshRevocationExported {
out,
count: revocations.len(),
})
}
ControlRequest::ModuleStub { module, command } => {
Ok(ControlResponse::NotImplemented { module, command })
}
@ -242,3 +437,100 @@ fn stored_resource_to_descriptor(stored: StoredResource) -> Result<ResourceDescr
fn stable_node_id(agent_id: &str) -> String {
format!("node:{agent_id}")
}
fn expected_openssh_cert_path(public_key_path: &Path) -> String {
let text = public_key_path.display().to_string();
if let Some(prefix) = text.strip_suffix(".pub") {
format!("{prefix}-cert.pub")
} else {
format!("{text}-cert.pub")
}
}
fn stored_from_ssh_cert_request(request: &SshCertRequest) -> StoredSshCertRequest {
StoredSshCertRequest {
request_id: request.id.to_string(),
requester_node: request.requester_node.to_string(),
public_key: request.public_key.clone(),
public_key_fingerprint: request.public_key_fingerprint.clone(),
cert_kind: request.cert_kind.to_string(),
principals: request.principals.clone(),
requested_validity: request.requested_validity.clone(),
renewal_of: request.renewal_of.as_ref().map(ToString::to_string),
reason: request.reason.clone(),
status: request.status.to_string(),
created_at_ms: request.created_at.0,
}
}
fn ssh_cert_request_from_stored(stored: StoredSshCertRequest) -> Result<SshCertRequest, NodeError> {
let cert_kind = stored
.cert_kind
.parse::<SshCertKind>()
.map_err(|_| NodeError::InvalidSshCertKind(stored.cert_kind.clone()))?;
let status = stored
.status
.parse::<SshCertRequestStatus>()
.map_err(|_| NodeError::InvalidSshCertStatus(stored.status.clone()))?;
Ok(SshCertRequest {
id: SshCertRequestId::new(stored.request_id),
requester_node: NodeId::new(stored.requester_node),
public_key: stored.public_key,
public_key_fingerprint: stored.public_key_fingerprint,
cert_kind,
principals: stored.principals,
requested_validity: stored.requested_validity,
renewal_of: stored.renewal_of.map(SshCertId::new),
reason: stored.reason,
status,
created_at: UnixMillis(stored.created_at_ms),
})
}
fn stored_from_ssh_certificate(certificate: &SshCertificateRecord) -> StoredSshCertificate {
StoredSshCertificate {
cert_id: certificate.id.to_string(),
request_id: certificate.request_id.to_string(),
certificate: certificate.certificate.clone(),
certificate_fingerprint: certificate.certificate_fingerprint.clone(),
imported_at_ms: certificate.imported_at.0,
}
}
fn ssh_certificate_from_stored(stored: StoredSshCertificate) -> SshCertificateRecord {
SshCertificateRecord {
id: SshCertId::new(stored.cert_id),
request_id: SshCertRequestId::new(stored.request_id),
certificate: stored.certificate,
certificate_fingerprint: stored.certificate_fingerprint,
imported_at: UnixMillis(stored.imported_at_ms),
}
}
fn stored_from_ssh_revocation(revocation: &SshRevocationEntry) -> StoredSshRevocation {
StoredSshRevocation {
revocation_id: revocation.id.to_string(),
kind: revocation.kind.to_string(),
target: revocation.target.clone(),
reason: revocation.reason.clone(),
created_at_ms: revocation.created_at.0,
published: revocation.published,
}
}
fn ssh_revocation_from_stored(
stored: StoredSshRevocation,
) -> Result<SshRevocationEntry, NodeError> {
let kind = stored
.kind
.parse::<SshRevocationKind>()
.map_err(|_| NodeError::InvalidSshRevocationKind(stored.kind.clone()))?;
Ok(SshRevocationEntry {
id: geth_types::SshRevocationId::new(stored.revocation_id),
kind,
target: stored.target,
reason: stored.reason,
created_at: UnixMillis(stored.created_at_ms),
published: stored.published,
})
}

View file

@ -0,0 +1,622 @@
use geth_config::GethPaths;
use std::path::{Path, PathBuf};
use std::process::Command;
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServiceManager {
Auto,
SystemdUser,
LaunchdUser,
WindowsTask,
}
impl ServiceManager {
pub fn detect() -> Result<Self, ServiceError> {
match std::env::consts::OS {
"linux" => Ok(Self::SystemdUser),
"macos" => Ok(Self::LaunchdUser),
"windows" => Ok(Self::WindowsTask),
other => Err(ServiceError::UnsupportedPlatform(other.to_owned())),
}
}
pub fn resolve(self) -> Result<Self, ServiceError> {
match self {
Self::Auto => Self::detect(),
manager => Ok(manager),
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Self::Auto => "auto",
Self::SystemdUser => "systemd-user",
Self::LaunchdUser => "launchd-user",
Self::WindowsTask => "windows-task",
}
}
}
impl std::fmt::Display for ServiceManager {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.as_str())
}
}
impl std::str::FromStr for ServiceManager {
type Err = ServiceError;
fn from_str(value: &str) -> Result<Self, Self::Err> {
match value {
"auto" => Ok(Self::Auto),
"systemd" | "systemd-user" => Ok(Self::SystemdUser),
"launchd" | "launchd-user" => Ok(Self::LaunchdUser),
"windows" | "windows-task" | "scheduled-task" => Ok(Self::WindowsTask),
_ => Err(ServiceError::InvalidManager(value.to_owned())),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ServiceAction {
Installed,
Uninstalled,
Started,
Stopped,
Status,
Printed,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ServiceReport {
pub manager: ServiceManager,
pub action: ServiceAction,
pub service_name: String,
pub definition_path: Option<PathBuf>,
pub definition: Option<String>,
pub commands: Vec<Vec<String>>,
pub note: String,
}
#[derive(Clone, Debug)]
pub struct ServiceInstallOptions {
pub manager: ServiceManager,
pub executable: PathBuf,
pub start: bool,
}
#[derive(Debug, thiserror::Error)]
pub enum ServiceError {
#[error("unsupported service platform: {0}")]
UnsupportedPlatform(String),
#[error("invalid service manager: {0}")]
InvalidManager(String),
#[error("could not determine home directory")]
MissingHome,
#[error("io error: {0}")]
Io(#[from] std::io::Error),
#[error("service command failed: {program} {args:?}: {stderr}")]
CommandFailed {
program: String,
args: Vec<String>,
stderr: String,
},
}
pub fn install_user_service(
paths: &GethPaths,
options: ServiceInstallOptions,
) -> Result<ServiceReport, ServiceError> {
let manager = options.manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
install_systemd_user(paths, &options.executable, options.start)
}
ServiceManager::LaunchdUser => {
install_launchd_user(paths, &options.executable, options.start)
}
ServiceManager::WindowsTask => {
install_windows_task(paths, &options.executable, options.start)
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn uninstall_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => uninstall_systemd_user(),
ServiceManager::LaunchdUser => uninstall_launchd_user(),
ServiceManager::WindowsTask => uninstall_windows_task(),
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn start_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
let command = run_command("systemctl", &["--user", "start", SYSTEMD_UNIT])?;
Ok(report(
manager,
ServiceAction::Started,
Some(systemd_unit_path()?),
None,
vec![command],
"started systemd user service",
))
}
ServiceManager::LaunchdUser => {
let command = run_command(
"launchctl",
&["load", "-w", &launchd_plist_path()?.display().to_string()],
)?;
Ok(report(
manager,
ServiceAction::Started,
Some(launchd_plist_path()?),
None,
vec![command],
"loaded launchd user agent",
))
}
ServiceManager::WindowsTask => {
let command = run_command("schtasks", &["/Run", "/TN", WINDOWS_TASK_NAME])?;
Ok(report(
manager,
ServiceAction::Started,
None,
None,
vec![command],
"started Windows per-user scheduled task",
))
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn stop_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
let command = run_command("systemctl", &["--user", "stop", SYSTEMD_UNIT])?;
Ok(report(
manager,
ServiceAction::Stopped,
Some(systemd_unit_path()?),
None,
vec![command],
"stopped systemd user service",
))
}
ServiceManager::LaunchdUser => {
let command = run_command(
"launchctl",
&["unload", &launchd_plist_path()?.display().to_string()],
)?;
Ok(report(
manager,
ServiceAction::Stopped,
Some(launchd_plist_path()?),
None,
vec![command],
"unloaded launchd user agent",
))
}
ServiceManager::WindowsTask => {
let command = run_command("schtasks", &["/End", "/TN", WINDOWS_TASK_NAME])?;
Ok(report(
manager,
ServiceAction::Stopped,
None,
None,
vec![command],
"stopped Windows per-user scheduled task",
))
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn status_user_service(manager: ServiceManager) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
match manager {
ServiceManager::SystemdUser => {
let command = run_command(
"systemctl",
&["--user", "status", SYSTEMD_UNIT, "--no-pager"],
)?;
Ok(report(
manager,
ServiceAction::Status,
Some(systemd_unit_path()?),
None,
vec![command],
"queried systemd user service",
))
}
ServiceManager::LaunchdUser => {
let command = run_command("launchctl", &["list", LAUNCHD_LABEL])?;
Ok(report(
manager,
ServiceAction::Status,
Some(launchd_plist_path()?),
None,
vec![command],
"queried launchd user agent",
))
}
ServiceManager::WindowsTask => {
let command = run_command("schtasks", &["/Query", "/TN", WINDOWS_TASK_NAME])?;
Ok(report(
manager,
ServiceAction::Status,
None,
None,
vec![command],
"queried Windows per-user scheduled task",
))
}
ServiceManager::Auto => unreachable!("auto is resolved above"),
}
}
pub fn print_user_service(
paths: &GethPaths,
manager: ServiceManager,
executable: &Path,
) -> Result<ServiceReport, ServiceError> {
let manager = manager.resolve()?;
let (path, definition) = match manager {
ServiceManager::SystemdUser => {
(Some(systemd_unit_path()?), systemd_unit(paths, executable))
}
ServiceManager::LaunchdUser => (
Some(launchd_plist_path()?),
launchd_plist(paths, executable),
),
ServiceManager::WindowsTask => (None, windows_task_command(paths, executable)),
ServiceManager::Auto => unreachable!("auto is resolved above"),
};
Ok(report(
manager,
ServiceAction::Printed,
path,
Some(definition),
Vec::new(),
"printed user service definition",
))
}
const SYSTEMD_UNIT: &str = "geth.service";
const LAUNCHD_LABEL: &str = "local.geth.daemon";
const WINDOWS_TASK_NAME: &str = "geth-daemon";
fn install_systemd_user(
paths: &GethPaths,
executable: &Path,
start: bool,
) -> Result<ServiceReport, ServiceError> {
let unit_path = systemd_unit_path()?;
if let Some(parent) = unit_path.parent() {
std::fs::create_dir_all(parent)?;
}
let definition = systemd_unit(paths, executable);
std::fs::write(&unit_path, &definition)?;
let mut commands = vec![
run_command("systemctl", &["--user", "daemon-reload"])?,
run_command("systemctl", &["--user", "enable", SYSTEMD_UNIT])?,
];
if start {
commands.push(run_command(
"systemctl",
&["--user", "start", SYSTEMD_UNIT],
)?);
}
Ok(report(
ServiceManager::SystemdUser,
ServiceAction::Installed,
Some(unit_path),
Some(definition),
commands,
"installed systemd user service; lingering may be needed for startup before login",
))
}
fn uninstall_systemd_user() -> Result<ServiceReport, ServiceError> {
let unit_path = systemd_unit_path()?;
let mut commands = Vec::new();
commands.push(run_command(
"systemctl",
&["--user", "disable", "--now", SYSTEMD_UNIT],
)?);
if unit_path.exists() {
std::fs::remove_file(&unit_path)?;
}
commands.push(run_command("systemctl", &["--user", "daemon-reload"])?);
Ok(report(
ServiceManager::SystemdUser,
ServiceAction::Uninstalled,
Some(unit_path),
None,
commands,
"uninstalled systemd user service",
))
}
fn install_launchd_user(
paths: &GethPaths,
executable: &Path,
start: bool,
) -> Result<ServiceReport, ServiceError> {
let plist_path = launchd_plist_path()?;
if let Some(parent) = plist_path.parent() {
std::fs::create_dir_all(parent)?;
}
let definition = launchd_plist(paths, executable);
std::fs::write(&plist_path, &definition)?;
let mut commands = vec![run_command(
"launchctl",
&["load", "-w", &plist_path.display().to_string()],
)?];
if start {
commands.push(run_command("launchctl", &["start", LAUNCHD_LABEL])?);
}
Ok(report(
ServiceManager::LaunchdUser,
ServiceAction::Installed,
Some(plist_path),
Some(definition),
commands,
"installed launchd user agent",
))
}
fn uninstall_launchd_user() -> Result<ServiceReport, ServiceError> {
let plist_path = launchd_plist_path()?;
let mut commands = Vec::new();
if plist_path.exists() {
commands.push(run_command(
"launchctl",
&["unload", &plist_path.display().to_string()],
)?);
std::fs::remove_file(&plist_path)?;
}
Ok(report(
ServiceManager::LaunchdUser,
ServiceAction::Uninstalled,
Some(plist_path),
None,
commands,
"uninstalled launchd user agent",
))
}
fn install_windows_task(
paths: &GethPaths,
executable: &Path,
start: bool,
) -> Result<ServiceReport, ServiceError> {
let task_command = windows_task_run_command(paths, executable);
let mut commands = vec![run_command(
"schtasks",
&[
"/Create",
"/TN",
WINDOWS_TASK_NAME,
"/SC",
"ONLOGON",
"/TR",
&task_command,
"/F",
],
)?];
if start {
commands.push(run_command(
"schtasks",
&["/Run", "/TN", WINDOWS_TASK_NAME],
)?);
}
Ok(report(
ServiceManager::WindowsTask,
ServiceAction::Installed,
None,
Some(task_command),
commands,
"installed Windows per-user scheduled task",
))
}
fn uninstall_windows_task() -> Result<ServiceReport, ServiceError> {
let command = run_command("schtasks", &["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"])?;
Ok(report(
ServiceManager::WindowsTask,
ServiceAction::Uninstalled,
None,
None,
vec![command],
"uninstalled Windows per-user scheduled task",
))
}
fn systemd_unit(paths: &GethPaths, executable: &Path) -> String {
format!(
r#"[Unit]
Description=geth personal mesh daemon
Documentation=https://example.invalid/local/geth
[Service]
Type=simple
Environment=GETH_HOME={}
ExecStart={} daemon run
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
"#,
systemd_escape(paths.home()),
systemd_escape(executable)
)
}
fn launchd_plist(paths: &GethPaths, executable: &Path) -> String {
format!(
r#"<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>Label</key>
<string>{}</string>
<key>ProgramArguments</key>
<array>
<string>{}</string>
<string>daemon</string>
<string>run</string>
</array>
<key>EnvironmentVariables</key>
<dict>
<key>GETH_HOME</key>
<string>{}</string>
</dict>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>{}/daemon.out.log</string>
<key>StandardErrorPath</key>
<string>{}/daemon.err.log</string>
</dict>
</plist>
"#,
LAUNCHD_LABEL,
xml_escape(&executable.display().to_string()),
xml_escape(&paths.home().display().to_string()),
xml_escape(&paths.run_dir().display().to_string()),
xml_escape(&paths.run_dir().display().to_string())
)
}
fn windows_task_command(paths: &GethPaths, executable: &Path) -> String {
windows_task_run_command(paths, executable)
}
fn windows_task_run_command(paths: &GethPaths, executable: &Path) -> String {
format!(
r#"cmd.exe /C "set GETH_HOME={}&& "{}" daemon run""#,
paths.home().display(),
executable.display()
)
}
fn systemd_unit_path() -> Result<PathBuf, ServiceError> {
Ok(home_dir()?.join(".config/systemd/user").join(SYSTEMD_UNIT))
}
fn launchd_plist_path() -> Result<PathBuf, ServiceError> {
Ok(home_dir()?
.join("Library/LaunchAgents")
.join(format!("{LAUNCHD_LABEL}.plist")))
}
fn home_dir() -> Result<PathBuf, ServiceError> {
std::env::var_os("HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("USERPROFILE").map(PathBuf::from))
.ok_or(ServiceError::MissingHome)
}
fn run_command(program: &str, args: &[&str]) -> Result<Vec<String>, ServiceError> {
let output = Command::new(program).args(args).output()?;
let command = std::iter::once(program.to_owned())
.chain(args.iter().map(|arg| (*arg).to_owned()))
.collect::<Vec<_>>();
if output.status.success() {
Ok(command)
} else {
Err(ServiceError::CommandFailed {
program: program.to_owned(),
args: args.iter().map(|arg| (*arg).to_owned()).collect(),
stderr: String::from_utf8_lossy(&output.stderr).trim().to_owned(),
})
}
}
fn report(
manager: ServiceManager,
action: ServiceAction,
definition_path: Option<PathBuf>,
definition: Option<String>,
commands: Vec<Vec<String>>,
note: &str,
) -> ServiceReport {
ServiceReport {
service_name: match manager {
ServiceManager::SystemdUser => SYSTEMD_UNIT.to_owned(),
ServiceManager::LaunchdUser => LAUNCHD_LABEL.to_owned(),
ServiceManager::WindowsTask => WINDOWS_TASK_NAME.to_owned(),
ServiceManager::Auto => "geth".to_owned(),
},
manager,
action,
definition_path,
definition,
commands,
note: note.to_owned(),
}
}
fn systemd_escape(path: &Path) -> String {
let value = path.display().to_string();
if value.bytes().any(|byte| byte.is_ascii_whitespace()) {
format!("\"{}\"", value.replace('"', "\\\""))
} else {
value
}
}
fn xml_escape(value: &str) -> String {
value
.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
.replace('\'', "&apos;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn systemd_unit_is_user_service() {
let paths = GethPaths::from_home("/tmp/geth home");
let unit = systemd_unit(&paths, Path::new("/usr/local/bin/geth"));
assert!(unit.contains("WantedBy=default.target"));
assert!(unit.contains("GETH_HOME="));
assert!(unit.contains("ExecStart=/usr/local/bin/geth daemon run"));
assert!(!unit.contains("WantedBy=multi-user.target"));
}
#[test]
fn launchd_plist_is_user_agent() {
let paths = GethPaths::from_home("/Users/eric/Library/Application Support/geth");
let plist = launchd_plist(&paths, Path::new("/usr/local/bin/geth"));
assert!(plist.contains("<string>local.geth.daemon</string>"));
assert!(plist.contains("<string>daemon</string>"));
assert!(plist.contains("<key>RunAtLoad</key>"));
}
#[test]
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("GETH_HOME="));
assert!(command.contains("daemon run"));
}
}