use std::collections::BTreeMap;
use std::fs;
use std::io::ErrorKind;
use std::path::{Path, PathBuf};
#[cfg(unix)]
use std::os::unix::fs::PermissionsExt;
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct SystemdUnitSpec {
pub slug: String,
pub description: String,
pub working_dir: PathBuf,
pub start_command: String,
pub unit_after: Vec<String>,
pub unit_wants: Vec<String>,
pub environment: BTreeMap<String, String>,
pub environment_files: Vec<String>,
pub config_paths: Vec<String>,
pub read_write_paths: Vec<String>,
pub runtime_directories: Vec<String>,
pub state_directories: Vec<String>,
pub service_directives: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RenderedUnit {
pub filename: String,
pub content: String,
pub written_path: Option<PathBuf>,
pub write_error: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum UnitWriteError {
PermissionDenied(String),
Other(String),
}
pub fn build_api_unit_spec(exe_dir: &Path, exe: &Path, port: u16) -> SystemdUnitSpec {
let mut environment = BTreeMap::new();
environment.insert("XBP_API_BIND".to_string(), "127.0.0.1".to_string());
environment.insert("PORT_XBP_API".to_string(), port.to_string());
SystemdUnitSpec {
slug: "xbp-api".to_string(),
description: "XBP API Server".to_string(),
working_dir: exe_dir.to_path_buf(),
start_command: exe.display().to_string(),
unit_after: vec!["network.target".to_string()],
unit_wants: vec!["network-online.target".to_string()],
environment,
environment_files: vec!["/etc/default/xbp".to_string()],
config_paths: vec![],
read_write_paths: vec![
"/var/log".to_string(),
"/etc/nginx".to_string(),
"/etc/systemd/system".to_string(),
"/run".to_string(),
],
runtime_directories: vec!["xbp".to_string()],
state_directories: vec!["xbp".to_string()],
service_directives: vec![
"NoNewPrivileges=yes".to_string(),
"PrivateTmp=yes".to_string(),
"ProtectSystem=full".to_string(),
"ProtectHome=read-only".to_string(),
"CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_DAC_OVERRIDE CAP_CHOWN CAP_SETUID CAP_SETGID"
.to_string(),
],
}
}
pub fn unit_file_name(slug: &str) -> String {
format!("{}.service", slug)
}
pub fn render_unit(spec: &SystemdUnitSpec) -> String {
let mut lines = vec![
"[Unit]".to_string(),
format!("Description={}", spec.description),
];
for after in &spec.unit_after {
lines.push(format!("After={}", after));
}
for wants in &spec.unit_wants {
lines.push(format!("Wants={}", wants));
}
lines.extend([
String::new(),
"[Service]".to_string(),
"Type=simple".to_string(),
format!("WorkingDirectory={}", spec.working_dir.display()),
]);
for file in &spec.environment_files {
lines.push(format!("EnvironmentFile=-{}", file));
}
for path in &spec.config_paths {
lines.push(format!("ReadOnlyPaths={}", path));
}
for path in &spec.read_write_paths {
lines.push(format!("ReadWritePaths={}", path));
}
for directory in &spec.runtime_directories {
lines.push(format!("RuntimeDirectory={}", directory));
}
for directory in &spec.state_directories {
lines.push(format!("StateDirectory={}", directory));
}
for (key, value) in &spec.environment {
lines.push(format!(
"Environment=\"{}={}\"",
key,
escape_environment(value)
));
}
lines.extend([
format!("ExecStart={}", spec.start_command),
"Restart=always".to_string(),
"RestartSec=10".to_string(),
]);
for directive in &spec.service_directives {
lines.push(directive.clone());
}
lines.extend([
String::new(),
"[Install]".to_string(),
"WantedBy=multi-user.target".to_string(),
]);
lines.join("\n")
}
pub fn escape_environment(value: &str) -> String {
value.replace('\\', "\\\\").replace('"', "\\\"")
}
pub fn render_unit_and_store(
unit: &SystemdUnitSpec,
directory: &Path,
) -> Result<RenderedUnit, String> {
let content = render_unit(unit);
let filename = unit_file_name(&unit.slug);
match write_unit_file(&filename, directory, &content) {
Ok(path) => Ok(RenderedUnit {
filename,
content,
written_path: Some(path),
write_error: None,
}),
Err(UnitWriteError::PermissionDenied(err)) => Ok(RenderedUnit {
filename,
content,
written_path: None,
write_error: Some(err),
}),
Err(UnitWriteError::Other(err)) => Err(err),
}
}
pub fn write_unit_file(
filename: &str,
directory: &Path,
content: &str,
) -> Result<PathBuf, UnitWriteError> {
let path = directory.join(filename);
fs::write(&path, content).map_err(|e| {
let message = format!("Failed to write {}: {}", path.display(), e);
if e.kind() == ErrorKind::PermissionDenied {
UnitWriteError::PermissionDenied(message)
} else {
UnitWriteError::Other(message)
}
})?;
Ok(path)
}
pub fn create_install_script(
project_root: &Path,
output_dir: &Path,
units: &[RenderedUnit],
) -> Result<PathBuf, String> {
let script_dir = project_root.join(".xbp");
fs::create_dir_all(&script_dir).map_err(|e| {
format!(
"Failed to prepare script directory {}: {}",
script_dir.display(),
e
)
})?;
let script_path = script_dir.join("install-systemd-units.sh");
let mut script_content = String::from("#!/bin/sh\nset -euo pipefail\n\n");
for unit in units {
let target = output_dir.join(&unit.filename);
script_content.push_str(&format!(
"sudo tee {} <<'EOF'\n{}\nEOF\n\n",
target.display(),
unit.content
));
}
script_content.push_str("sudo systemctl daemon-reload\n");
fs::write(&script_path, script_content)
.map_err(|e| format!("Failed to write {}: {}", script_path.display(), e))?;
make_executable(&script_path)?;
Ok(script_path)
}
pub fn make_executable(path: &Path) -> Result<(), String> {
#[cfg(unix)]
{
let mut permissions = fs::metadata(path)
.map_err(|e| format!("Failed to read metadata for {}: {}", path.display(), e))?
.permissions();
permissions.set_mode(0o755);
fs::set_permissions(path, permissions)
.map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?;
}
#[cfg(not(unix))]
{
let _ = path;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
#[test]
fn render_unit_includes_environment_and_paths() {
let mut environment = BTreeMap::new();
environment.insert("A".to_string(), "1".to_string());
let spec = SystemdUnitSpec {
slug: "demo".to_string(),
description: "Demo".to_string(),
working_dir: PathBuf::from("/srv/demo"),
start_command: "/bin/demo".to_string(),
unit_after: vec!["network.target".to_string()],
unit_wants: vec![],
environment,
environment_files: vec!["/etc/default/demo".to_string()],
config_paths: vec!["/etc/demo/config.yaml".to_string()],
read_write_paths: vec!["/var/log/demo".to_string()],
runtime_directories: vec!["demo".to_string()],
state_directories: vec!["demo".to_string()],
service_directives: vec!["ProtectSystem=full".to_string()],
};
let rendered = render_unit(&spec);
assert!(rendered.contains("EnvironmentFile=-/etc/default/demo"));
assert!(rendered.contains("ReadOnlyPaths=/etc/demo/config.yaml"));
assert!(rendered.contains("ReadWritePaths=/var/log/demo"));
assert!(rendered.contains("RuntimeDirectory=demo"));
assert!(rendered.contains("StateDirectory=demo"));
}
#[test]
fn api_unit_spec_keeps_hardening_and_runtime_paths() {
let spec = build_api_unit_spec(Path::new("/srv/bin"), Path::new("/srv/bin/xbp"), 8080);
let rendered = render_unit(&spec);
assert!(rendered.contains("EnvironmentFile=-/etc/default/xbp"));
assert!(rendered.contains("NoNewPrivileges=yes"));
assert!(rendered.contains("PrivateTmp=yes"));
assert!(rendered.contains("ProtectSystem=full"));
assert!(rendered.contains("ProtectHome=read-only"));
assert!(rendered.contains("ReadWritePaths=/etc/systemd/system"));
assert!(rendered.contains("StateDirectory=xbp"));
}
}