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}