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