use anyhow::{Context, Result};
use std::{
ffi::OsString,
os::unix::fs::PermissionsExt,
path::{Path, PathBuf},
process::{Command, ExitStatus},
};
const UNIT_NAME: &str = "blip.service";
const UNIT_PATH: &str = "/etc/systemd/system/blip.service";
const DEFAULT_SOURCE_DIR: &str = "/usr/local/src/blip";
pub fn is_root() -> bool {
unsafe { libc::geteuid() == 0 }
}
pub fn elevate_self() -> Result<()> {
if is_root() {
return Ok(());
}
let executable = std::env::current_exe().context("locate current executable")?;
let arguments = std::env::args_os().skip(1);
let status = Command::new("sudo")
.arg("--")
.arg(executable)
.args(arguments)
.status()
.context("run sudo")?;
std::process::exit(status.code().unwrap_or(1));
}
pub fn service_user(requested: Option<String>) -> Result<String> {
let user = requested
.or_else(|| std::env::var("SUDO_USER").ok())
.or_else(|| std::env::var("USER").ok())
.context("set --user to a non-root service account")?;
if user == "root" {
anyhow::bail!("the Blip service must not run as root");
}
command_success(Command::new("id").arg(&user), "find service user")?;
Ok(user)
}
pub fn install_service(config_path: &Path, user: &str, start: bool) -> Result<()> {
require_root()?;
let executable = std::env::current_exe()
.context("locate current executable")?
.canonicalize()
.context("resolve current executable")?;
let config_path = config_path
.canonicalize()
.with_context(|| format!("resolve config {}", config_path.display()))?;
let group = command_output("id", &["-gn", user], "find service group")?;
let data_dir = config_path
.parent()
.context("configuration path has no parent directory")?;
command_success(
Command::new("install")
.args(["-d", "-m", "0750", "-o", user, "-g", &group])
.arg(data_dir),
"create Blip data directory",
)?;
command_success(
Command::new("chown")
.arg(format!("root:{group}"))
.arg(&config_path),
"set config ownership",
)?;
std::fs::set_permissions(&config_path, std::fs::Permissions::from_mode(0o640))
.context("set config permissions")?;
for name in [
"blip-history.jsonl",
"blip-deliveries.jsonl",
"blip.queue.lock",
] {
let runtime_path = data_dir.join(name);
if runtime_path.exists() {
command_success(
Command::new("chown")
.arg(format!("{user}:{group}"))
.arg(&runtime_path),
"set runtime file ownership",
)?;
}
}
let unit = service_unit(&executable, &config_path, data_dir, user, &group);
let temporary = format!("{UNIT_PATH}.tmp.{}", std::process::id());
std::fs::write(&temporary, unit).context("write temporary systemd unit")?;
std::fs::set_permissions(&temporary, std::fs::Permissions::from_mode(0o644))?;
std::fs::rename(&temporary, UNIT_PATH).context("install systemd unit")?;
systemctl(&["daemon-reload"], false)?;
systemctl(&["enable", UNIT_NAME], false)?;
if start {
systemctl(&["restart", UNIT_NAME], false)?;
}
Ok(())
}
fn service_unit(
executable: &Path,
config_path: &Path,
data_dir: &Path,
user: &str,
group: &str,
) -> String {
format!(
"[Unit]\n\
Description=Blip webhook deployment service\n\
After=network-online.target\n\
Wants=network-online.target\n\n\
[Service]\n\
Type=simple\n\
User={user}\n\
Group={group}\n\
WorkingDirectory={}\n\
ExecStart={} --config {} serve\n\
Restart=on-failure\n\
RestartSec=5s\n\
KillMode=mixed\n\
TimeoutStopSec=infinity\n\
UMask=0027\n\
NoNewPrivileges=true\n\n\
[Install]\n\
WantedBy=multi-user.target\n",
data_dir.display(),
executable.display(),
config_path.display()
)
}
pub fn upgrade() -> Result<()> {
let source_dir = std::env::var_os("BLIP_SOURCE_DIR")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from(DEFAULT_SOURCE_DIR));
let installer = source_dir.join("docs/install.sh");
let build_user = std::env::var("BLIP_USER")
.ok()
.filter(|user| user != "root")
.or_else(|| {
std::env::var("SUDO_USER")
.ok()
.filter(|user| user != "root")
})
.or_else(|| std::env::var("USER").ok().filter(|user| user != "root"))
.context("cannot determine the non-root build user")?;
if !installer.is_file() {
return upgrade_from_crates(&build_user);
}
let mut command = upgrade_command(&source_dir, &build_user, !is_root());
for name in [
"BLIP_REPO_URL",
"BLIP_REPO_BRANCH",
"BLIP_INSTALL_DEPENDENCIES",
"BLIP_CONFIG_PATH",
] {
if let Some(value) = std::env::var_os(name) {
command.arg(format!("{name}={}", value.to_string_lossy()));
}
}
let status = command
.arg("bash")
.arg(&installer)
.status()
.with_context(|| format!("run upgrade installer {}", installer.display()))?;
ensure_success(status, "Blip upgrade")
}
fn upgrade_from_crates(build_user: &str) -> Result<()> {
let temporary = PathBuf::from(command_output(
"mktemp",
&["-d", "/tmp/blip-upgrade.XXXXXX"],
"create upgrade staging directory",
)?);
let (build_home, build_group) = account_details(build_user)?;
if is_root() {
command_success(
Command::new("chown")
.arg(format!("{build_user}:{build_group}"))
.arg(&temporary),
"set upgrade staging ownership",
)?;
}
let build_path = format!(
"{}/bin:{}/.cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
temporary.display(),
build_home.display()
);
let mut build = if is_root() {
let mut command = Command::new("runuser");
command.args(["-u", build_user, "--", "env"]);
command
} else {
let mut command = Command::new("sudo");
command.args(["-u", build_user, "-H", "--", "env"]);
command
};
build
.arg(format!("HOME={}", build_home.display()))
.arg(format!("USER={build_user}"))
.arg(format!("LOGNAME={build_user}"))
.arg(format!("PATH={build_path}"));
let result = (|| -> Result<()> {
let status = build
.args(["cargo", "install", "bliper", "--locked", "--root"])
.arg(&temporary)
.status()
.context("install Blip from crates.io")?;
ensure_success(status, "install Blip from crates.io")?;
let staged = temporary.join("bin/blip");
let status = privileged_command("install")
.args(["-m", "0755"])
.arg(&staged)
.arg("/usr/local/bin/blip")
.status()
.context("install upgraded Blip binary")?;
ensure_success(status, "install upgraded Blip binary")?;
let service_user = installed_service_user().unwrap_or_else(|| build_user.to_owned());
let (service_home, _) = account_details(&service_user)?;
let config_path = std::env::var_os("BLIP_CONFIG_PATH")
.map(PathBuf::from)
.unwrap_or_else(|| service_home.join(".local/share/blip/blip.toml"));
let mut service = privileged_command("/usr/local/bin/blip");
let status = service
.arg("--config")
.arg(config_path)
.args(["service", "install", "--user", &service_user])
.status()
.context("reinstall Blip service")?;
ensure_success(status, "reinstall Blip service")
})();
let _ = std::fs::remove_dir_all(&temporary);
result
}
fn privileged_command(program: &str) -> Command {
if is_root() {
Command::new(program)
} else {
let mut command = Command::new("sudo");
command.arg("--").arg(program);
command
}
}
fn account_details(user: &str) -> Result<(PathBuf, String)> {
let passwd = command_output("getent", &["passwd", user], "find account home")?;
let home = passwd
.split(':')
.nth(5)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.context("account has no home directory")?;
let group = command_output("id", &["-gn", user], "find account group")?;
Ok((home, group))
}
fn installed_service_user() -> Option<String> {
command_output(
"systemctl",
&["show", UNIT_NAME, "--property=User", "--value"],
"find installed service user",
)
.ok()
.filter(|user| !user.is_empty() && user != "root")
}
fn upgrade_command(source_dir: &Path, build_user: &str, use_sudo: bool) -> Command {
let mut command = if use_sudo {
let mut command = Command::new("sudo");
command.arg("--").arg("env");
command
} else {
Command::new("env")
};
command
.arg(format!("BLIP_USER={build_user}"))
.arg("BLIP_UPGRADE_ONLY=1")
.arg("BLIP_INSTALL_METHOD=crates")
.arg(format!("BLIP_SOURCE_DIR={}", source_dir.display()));
command
}
pub fn uninstall_service() -> Result<()> {
require_root()?;
let _ = systemctl(&["disable", "--now", UNIT_NAME], false);
match std::fs::remove_file(UNIT_PATH) {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(error).context("remove systemd unit"),
}
systemctl(&["daemon-reload"], false)?;
Ok(())
}
pub fn service_action(action: &str) -> Result<()> {
let privileged = matches!(action, "start" | "stop" | "restart" | "enable" | "disable");
let arguments = match action {
"status" => vec!["status", UNIT_NAME, "--no-pager"],
"enable" => vec!["enable", UNIT_NAME],
"disable" => vec!["disable", UNIT_NAME],
"start" | "stop" | "restart" => vec![action, UNIT_NAME],
_ => anyhow::bail!("unsupported service action: {action}"),
};
systemctl(&arguments, privileged)
}
pub fn logs(lines: usize, follow: bool, since: Option<&str>) -> Result<()> {
let mut arguments = vec![
OsString::from("--unit"),
OsString::from(UNIT_NAME),
OsString::from("--lines"),
OsString::from(lines.to_string()),
];
if follow {
arguments.push(OsString::from("--follow"));
} else {
arguments.push(OsString::from("--no-pager"));
}
if let Some(since) = since {
arguments.push(OsString::from("--since"));
arguments.push(OsString::from(since));
}
let status = Command::new("journalctl")
.args(&arguments)
.status()
.context("run journalctl")?;
if status.success() {
return Ok(());
}
if !is_root() {
let retry = Command::new("sudo")
.arg("--")
.arg("journalctl")
.args(&arguments)
.status()
.context("run journalctl with sudo")?;
ensure_success(retry, "journalctl")?;
return Ok(());
}
ensure_success(status, "journalctl")
}
fn require_root() -> Result<()> {
if !is_root() {
anyhow::bail!("this operation requires root privileges");
}
Ok(())
}
fn systemctl(arguments: &[&str], privileged: bool) -> Result<()> {
let status = if privileged && !is_root() {
Command::new("sudo")
.arg("--")
.arg("systemctl")
.args(arguments)
.status()
} else {
Command::new("systemctl").args(arguments).status()
}
.context("run systemctl")?;
ensure_success(status, "systemctl")
}
fn command_success(command: &mut Command, description: &str) -> Result<()> {
let status = command.status().with_context(|| description.to_string())?;
ensure_success(status, description)
}
fn command_output(program: &str, arguments: &[&str], description: &str) -> Result<String> {
let output = Command::new(program)
.args(arguments)
.output()
.with_context(|| description.to_string())?;
ensure_success(output.status, description)?;
Ok(String::from_utf8(output.stdout)
.context("command returned non-UTF-8 output")?
.trim()
.to_string())
}
fn ensure_success(status: ExitStatus, program: &str) -> Result<()> {
if status.success() {
Ok(())
} else {
anyhow::bail!("{program} exited with {status}")
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_unit_restarts_safely_and_allows_active_job_completion() {
let unit = service_unit(
Path::new("/usr/local/bin/blip"),
Path::new("/home/deploy/.local/share/blip/blip.toml"),
Path::new("/home/deploy/.local/share/blip"),
"deploy",
"deploy",
);
assert!(unit.contains("WorkingDirectory=/home/deploy/.local/share/blip"));
assert!(unit.contains(
"ExecStart=/usr/local/bin/blip --config /home/deploy/.local/share/blip/blip.toml serve"
));
assert!(unit.contains("KillMode=mixed"));
assert!(unit.contains("TimeoutStopSec=infinity"));
assert!(unit.contains("User=deploy"));
assert!(unit.contains("NoNewPrivileges=true"));
}
#[test]
fn upgrade_command_requests_narrow_elevation_and_upgrade_only_mode() {
let command = upgrade_command(Path::new("/usr/local/src/blip"), "builder", true);
assert_eq!(command.get_program(), "sudo");
let arguments = command
.get_args()
.map(|argument| argument.to_string_lossy().into_owned())
.collect::<Vec<_>>();
assert_eq!(
arguments,
[
"--",
"env",
"BLIP_USER=builder",
"BLIP_UPGRADE_ONLY=1",
"BLIP_INSTALL_METHOD=crates",
"BLIP_SOURCE_DIR=/usr/local/src/blip",
]
);
}
}