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}