Skip to main content

xbp_cli/commands/
systemd_unit.rs

1use std::collections::BTreeMap;
2use std::fs;
3use std::io::ErrorKind;
4use std::path::{Path, PathBuf};
5
6#[cfg(unix)]
7use std::os::unix::fs::PermissionsExt;
8
9#[derive(Debug, Clone, Default, PartialEq, Eq)]
10pub struct SystemdUnitSpec {
11    pub slug: String,
12    pub description: String,
13    pub working_dir: PathBuf,
14    pub start_command: String,
15    pub unit_after: Vec<String>,
16    pub unit_wants: Vec<String>,
17    pub environment: BTreeMap<String, String>,
18    pub environment_files: Vec<String>,
19    pub config_paths: Vec<String>,
20    pub read_write_paths: Vec<String>,
21    pub runtime_directories: Vec<String>,
22    pub state_directories: Vec<String>,
23    pub service_directives: Vec<String>,
24}
25
26#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct RenderedUnit {
28    pub filename: String,
29    pub content: String,
30    pub written_path: Option<PathBuf>,
31    pub write_error: Option<String>,
32}
33
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub enum UnitWriteError {
36    PermissionDenied(String),
37    Other(String),
38}
39
40pub fn build_api_unit_spec(exe_dir: &Path, exe: &Path, port: u16) -> SystemdUnitSpec {
41    let mut environment = BTreeMap::new();
42    environment.insert("XBP_API_BIND".to_string(), "127.0.0.1".to_string());
43    environment.insert("PORT_XBP_API".to_string(), port.to_string());
44
45    SystemdUnitSpec {
46        slug: "xbp-api".to_string(),
47        description: "XBP API Server".to_string(),
48        working_dir: exe_dir.to_path_buf(),
49        start_command: exe.display().to_string(),
50        unit_after: vec!["network.target".to_string()],
51        unit_wants: vec!["network-online.target".to_string()],
52        environment,
53        environment_files: vec!["/etc/default/xbp".to_string()],
54        config_paths: vec![],
55        read_write_paths: vec![
56            "/var/log".to_string(),
57            "/etc/nginx".to_string(),
58            "/etc/systemd/system".to_string(),
59            "/run".to_string(),
60        ],
61        runtime_directories: vec!["xbp".to_string()],
62        state_directories: vec!["xbp".to_string()],
63        service_directives: vec![
64            "NoNewPrivileges=yes".to_string(),
65            "PrivateTmp=yes".to_string(),
66            "ProtectSystem=full".to_string(),
67            "ProtectHome=read-only".to_string(),
68            "CapabilityBoundingSet=CAP_NET_BIND_SERVICE CAP_DAC_OVERRIDE CAP_CHOWN CAP_SETUID CAP_SETGID"
69                .to_string(),
70        ],
71    }
72}
73
74pub fn unit_file_name(slug: &str) -> String {
75    format!("{}.service", slug)
76}
77
78pub fn render_unit(spec: &SystemdUnitSpec) -> String {
79    let mut lines = vec![
80        "[Unit]".to_string(),
81        format!("Description={}", spec.description),
82    ];
83
84    for after in &spec.unit_after {
85        lines.push(format!("After={}", after));
86    }
87
88    for wants in &spec.unit_wants {
89        lines.push(format!("Wants={}", wants));
90    }
91
92    lines.extend([
93        String::new(),
94        "[Service]".to_string(),
95        "Type=simple".to_string(),
96        format!("WorkingDirectory={}", spec.working_dir.display()),
97    ]);
98
99    for file in &spec.environment_files {
100        lines.push(format!("EnvironmentFile=-{}", file));
101    }
102
103    for path in &spec.config_paths {
104        lines.push(format!("ReadOnlyPaths={}", path));
105    }
106
107    for path in &spec.read_write_paths {
108        lines.push(format!("ReadWritePaths={}", path));
109    }
110
111    for directory in &spec.runtime_directories {
112        lines.push(format!("RuntimeDirectory={}", directory));
113    }
114
115    for directory in &spec.state_directories {
116        lines.push(format!("StateDirectory={}", directory));
117    }
118
119    for (key, value) in &spec.environment {
120        lines.push(format!(
121            "Environment=\"{}={}\"",
122            key,
123            escape_environment(value)
124        ));
125    }
126
127    lines.extend([
128        format!("ExecStart={}", spec.start_command),
129        "Restart=always".to_string(),
130        "RestartSec=10".to_string(),
131    ]);
132
133    for directive in &spec.service_directives {
134        lines.push(directive.clone());
135    }
136
137    lines.extend([
138        String::new(),
139        "[Install]".to_string(),
140        "WantedBy=multi-user.target".to_string(),
141    ]);
142
143    lines.join("\n")
144}
145
146pub fn escape_environment(value: &str) -> String {
147    value.replace('\\', "\\\\").replace('"', "\\\"")
148}
149
150pub fn render_unit_and_store(
151    unit: &SystemdUnitSpec,
152    directory: &Path,
153) -> Result<RenderedUnit, String> {
154    let content = render_unit(unit);
155    let filename = unit_file_name(&unit.slug);
156    match write_unit_file(&filename, directory, &content) {
157        Ok(path) => Ok(RenderedUnit {
158            filename,
159            content,
160            written_path: Some(path),
161            write_error: None,
162        }),
163        Err(UnitWriteError::PermissionDenied(err)) => Ok(RenderedUnit {
164            filename,
165            content,
166            written_path: None,
167            write_error: Some(err),
168        }),
169        Err(UnitWriteError::Other(err)) => Err(err),
170    }
171}
172
173pub fn write_unit_file(
174    filename: &str,
175    directory: &Path,
176    content: &str,
177) -> Result<PathBuf, UnitWriteError> {
178    let path = directory.join(filename);
179    fs::write(&path, content).map_err(|e| {
180        let message = format!("Failed to write {}: {}", path.display(), e);
181        if e.kind() == ErrorKind::PermissionDenied {
182            UnitWriteError::PermissionDenied(message)
183        } else {
184            UnitWriteError::Other(message)
185        }
186    })?;
187    Ok(path)
188}
189
190pub fn create_install_script(
191    project_root: &Path,
192    output_dir: &Path,
193    units: &[RenderedUnit],
194) -> Result<PathBuf, String> {
195    let script_dir = project_root.join(".xbp");
196    fs::create_dir_all(&script_dir).map_err(|e| {
197        format!(
198            "Failed to prepare script directory {}: {}",
199            script_dir.display(),
200            e
201        )
202    })?;
203
204    let script_path = script_dir.join("install-systemd-units.sh");
205    let mut script_content = String::from("#!/bin/sh\nset -euo pipefail\n\n");
206    for unit in units {
207        let target = output_dir.join(&unit.filename);
208        script_content.push_str(&format!(
209            "sudo tee {} <<'EOF'\n{}\nEOF\n\n",
210            target.display(),
211            unit.content
212        ));
213    }
214    script_content.push_str("sudo systemctl daemon-reload\n");
215
216    fs::write(&script_path, script_content)
217        .map_err(|e| format!("Failed to write {}: {}", script_path.display(), e))?;
218
219    make_executable(&script_path)?;
220    Ok(script_path)
221}
222
223pub fn make_executable(path: &Path) -> Result<(), String> {
224    #[cfg(unix)]
225    {
226        let mut permissions = fs::metadata(path)
227            .map_err(|e| format!("Failed to read metadata for {}: {}", path.display(), e))?
228            .permissions();
229        permissions.set_mode(0o755);
230        fs::set_permissions(path, permissions)
231            .map_err(|e| format!("Failed to set permissions on {}: {}", path.display(), e))?;
232    }
233
234    #[cfg(not(unix))]
235    {
236        let _ = path;
237    }
238
239    Ok(())
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245    use std::collections::BTreeMap;
246
247    #[test]
248    fn render_unit_includes_environment_and_paths() {
249        let mut environment = BTreeMap::new();
250        environment.insert("A".to_string(), "1".to_string());
251        let spec = SystemdUnitSpec {
252            slug: "demo".to_string(),
253            description: "Demo".to_string(),
254            working_dir: PathBuf::from("/srv/demo"),
255            start_command: "/bin/demo".to_string(),
256            unit_after: vec!["network.target".to_string()],
257            unit_wants: vec![],
258            environment,
259            environment_files: vec!["/etc/default/demo".to_string()],
260            config_paths: vec!["/etc/demo/config.yaml".to_string()],
261            read_write_paths: vec!["/var/log/demo".to_string()],
262            runtime_directories: vec!["demo".to_string()],
263            state_directories: vec!["demo".to_string()],
264            service_directives: vec!["ProtectSystem=full".to_string()],
265        };
266
267        let rendered = render_unit(&spec);
268        assert!(rendered.contains("EnvironmentFile=-/etc/default/demo"));
269        assert!(rendered.contains("ReadOnlyPaths=/etc/demo/config.yaml"));
270        assert!(rendered.contains("ReadWritePaths=/var/log/demo"));
271        assert!(rendered.contains("RuntimeDirectory=demo"));
272        assert!(rendered.contains("StateDirectory=demo"));
273    }
274
275    #[test]
276    fn api_unit_spec_keeps_hardening_and_runtime_paths() {
277        let spec = build_api_unit_spec(Path::new("/srv/bin"), Path::new("/srv/bin/xbp"), 8080);
278        let rendered = render_unit(&spec);
279        assert!(rendered.contains("EnvironmentFile=-/etc/default/xbp"));
280        assert!(rendered.contains("NoNewPrivileges=yes"));
281        assert!(rendered.contains("PrivateTmp=yes"));
282        assert!(rendered.contains("ProtectSystem=full"));
283        assert!(rendered.contains("ProtectHome=read-only"));
284        assert!(rendered.contains("ReadWritePaths=/etc/systemd/system"));
285        assert!(rendered.contains("StateDirectory=xbp"));
286    }
287}