Add SSH cert flows and user service installer
This commit is contained in:
parent
26f81ff1ef
commit
f302342b1c
21 changed files with 2158 additions and 14 deletions
|
|
@ -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" }
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
})
|
||||
}
|
||||
|
|
|
|||
622
crates/geth-node/src/service.rs
Normal file
622
crates/geth-node/src/service.rs
Normal 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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
.replace('\'', "'")
|
||||
}
|
||||
|
||||
#[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"));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue