use std::ffi::OsStr;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context as _, Result, bail};
use super::{Manager, State, plist};
use crate::home::{Home, user_home, write_private};
use crate::install::{SANDBOX_BACKUP, backup_path};
pub const DISPLAY: &str = "launchd user agent";
pub fn manager(_home: &Home) -> Result<Box<dyn Manager>> {
Ok(Box::new(LaunchdManager { home: user_home()? }))
}
struct LaunchdManager {
home: PathBuf,
}
impl LaunchdManager {
fn plist_dir(&self) -> PathBuf {
self.home.join("Library/LaunchAgents")
}
fn plist_path(&self) -> PathBuf {
self.plist_dir().join(format!("{}.plist", plist::LABEL))
}
}
fn log_dir() -> Result<PathBuf> {
Ok(Home::locate()?.root().join("logs"))
}
fn target() -> Result<String> {
let uid = current_uid()?;
if uid == 0 {
bail!("`openmoat proxy install` is for a logged-in user, not root");
}
Ok(format!("gui/{uid}/{}", plist::LABEL))
}
fn gui_domain() -> Result<String> {
Ok(format!("gui/{}", current_uid()?))
}
fn launchctl<I, S>(args: I) -> Result<std::process::Output>
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
Command::new("launchctl").args(args).output().with_context(
|| "running launchctl; install launchd command-line tools or run `openmoat proxy` yourself",
)
}
impl Manager for LaunchdManager {
fn file_path(&self) -> Result<PathBuf> {
Ok(self.plist_path())
}
fn install(&self, binary: &Path, moat_home: &Path) -> Result<PathBuf> {
let dir = self.plist_dir();
fs::create_dir_all(&dir).with_context(|| format!("creating {}", dir.display()))?;
let logs = log_dir()?;
fs::create_dir_all(&logs).with_context(|| format!("creating {}", logs.display()))?;
let stdout_log = logs.join("proxy.out.log");
let stderr_log = logs.join("proxy.err.log");
let text = plist::render(&plist::Spec {
binary,
moat_home,
stdout_log: &stdout_log,
stderr_log: &stderr_log,
});
let path = self.plist_path();
if path.exists() {
let backup = backup_path(&path, SANDBOX_BACKUP);
fs::copy(&path, &backup).with_context(|| format!("backing up {}", path.display()))?;
}
write_private(&path, text.as_bytes())?;
if super::skip_exec() {
return Ok(path);
}
let _ = launchctl(["bootout", &target()?])?;
let out = launchctl(["bootstrap", &gui_domain()?, &path.to_string_lossy()])?;
if !out.status.success() {
bail!(
"launchctl bootstrap refused: {}; the plist is at {} and `launchctl \
bootstrap {} {}` runs it by hand",
String::from_utf8_lossy(&out.stderr).trim(),
path.display(),
gui_domain()?,
path.display()
);
}
let _ = launchctl(["kickstart", "-k", &target()?])?;
Ok(path)
}
fn uninstall(&self) -> Result<Option<PathBuf>> {
let path = self.plist_path();
if !path.exists() {
return Ok(None);
}
if !super::skip_exec() {
let _ = launchctl(["bootout", &target()?]);
}
fs::remove_file(&path).with_context(|| format!("removing {}", path.display()))?;
Ok(Some(path))
}
fn state(&self) -> Result<State> {
let path = self.plist_path();
if !path.exists() {
return Ok(State::NotInstalled);
}
if super::skip_exec() {
return Ok(State::Stopped);
}
let out = match launchctl(["print", &target()?]) {
Ok(out) => out,
Err(e) => {
return Ok(State::Unknown {
details: format!("{e:#}"),
});
}
};
if !out.status.success() {
return Ok(State::Stopped);
}
let stdout = String::from_utf8_lossy(&out.stdout);
if stdout.contains("state = running") || stdout.contains("\n\tpid = ") {
return Ok(State::Running);
}
if let Some(code) = parse_last_exit(&stdout) {
if code == 64 {
return Ok(State::DriftBlocked);
}
if code != 0 {
return Ok(State::Crashlooping);
}
}
Ok(State::Stopped)
}
fn restart(&self) -> Result<()> {
let path = self.plist_path();
if !path.exists() || super::skip_exec() {
return Ok(());
}
let out = launchctl(["kickstart", "-k", &target()?])?;
if !out.status.success() {
bail!(
"launchctl kickstart refused: {}; the proxy may already be stopped",
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
}
fn current_uid() -> Result<u32> {
let out = Command::new("/usr/bin/id")
.arg("-u")
.output()
.context("running /usr/bin/id to find the current uid")?;
if !out.status.success() {
bail!(
"/usr/bin/id -u failed: {}",
String::from_utf8_lossy(&out.stderr).trim()
);
}
let text = String::from_utf8_lossy(&out.stdout);
text.trim()
.parse::<u32>()
.with_context(|| format!("parsing uid {text:?}"))
}
fn parse_last_exit(text: &str) -> Option<i32> {
text.lines()
.find_map(|l| l.trim().strip_prefix("last exit code = "))
.and_then(|rest| rest.split_whitespace().next())
.and_then(|n| n.parse().ok())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parse_last_exit_reads_the_exit_code_line() {
let text = "foo = bar\n\tlast exit code = 64\nmore";
assert_eq!(parse_last_exit(text), Some(64));
assert_eq!(parse_last_exit("no exit code here"), None);
let crash = "\tlast exit code = 139 (signal 11)\n";
assert_eq!(parse_last_exit(crash), Some(139));
}
}