1use std::path::{Path, PathBuf};
2
3use crate::config::{Config, GpuMode};
4use crate::env::HostEnv;
5use crate::xdg::ResolvedXdgDirs;
6
7fn home() -> PathBuf {
8 dirs::home_dir().unwrap_or_else(|| PathBuf::from("/root"))
9}
10
11pub fn generate_build(config: &Config, containerfile_path: &Path) -> String {
13 let mut lines: Vec<String> = Vec::new();
14
15 lines.push("[Build]".into());
16 lines.push(format!(
17 "ImageTag=localhost/podbox-{}:latest",
18 config.image.name
19 ));
20 lines.push(format!("File={}", containerfile_path.to_string_lossy()));
21 lines.push(format!("Retry={}", config.image.pull_retry));
22 lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
23
24 lines.join("\n")
25}
26
27pub fn generate_socket(config: &Config) -> String {
29 let name = &config.container.name;
30 let host_service = format!("{}-host.service", name);
31 let mut lines: Vec<String> = Vec::new();
32
33 lines.push("[Unit]".into());
34 lines.push(format!("Description=podbox host-guest socket -- {}", name));
35 lines.push(String::new());
36
37 lines.push("[Socket]".into());
38 lines.push(format!("ListenStream=%t/podbox/{}.sock", name));
39 lines.push(format!("Service={}", host_service));
40 lines.push("SocketMode=0600".into());
41 lines.push("DirectoryMode=0700".into());
42 lines.push("RuntimeDirectory=podbox".into());
43 lines.push("RuntimeDirectoryMode=0700".into());
44 lines.push(String::new());
45
46 lines.push("[Install]".into());
47 lines.push("WantedBy=sockets.target".into());
48
49 lines.join("\n")
50}
51
52pub fn generate_container(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs) -> String {
56 let name = &config.container.name;
57 let home_in_container = "/home/%u";
58 let mut lines: Vec<String> = Vec::new();
59
60 emit_unit(&mut lines, config, name);
61 emit_container_image(&mut lines, config, name, home_in_container, env);
62 emit_network(&mut lines, config);
63 emit_volumes(&mut lines, config, xdg, env, name, home_in_container);
64 emit_env(&mut lines, config, name, env);
65 emit_gpu(&mut lines, config, env);
66 emit_auto_update(&mut lines, config);
67 emit_podman_args(&mut lines, config);
68 emit_service_section(&mut lines, config);
69 emit_install_section(&mut lines, config);
70
71 lines.join("\n")
72}
73
74fn emit_unit(lines: &mut Vec<String>, config: &Config, name: &str) {
75 lines.push("[Unit]".into());
76 lines.push(format!("Description=podbox -- {}", name));
77 lines.push(format!("Requires={}.socket", name));
78 lines.push(format!("After={}.socket", name));
79 for dep in &config.systemd.requires {
80 lines.push(format!("Requires={}", dep));
81 }
82 for dep in &config.systemd.after {
83 lines.push(format!("After={}", dep));
84 }
85 if config.use_dbus_proxy() {
86 lines.push(format!("Requires={}-proxy.service", name));
87 lines.push(format!("After={}-proxy.service", name));
88 }
89 if config.use_wayland_proxy() {
90 lines.push(format!("Requires={}-compositor.service", name));
91 lines.push(format!("After={}-compositor.service", name));
92 }
93 lines.push("StartLimitBurst=5".into());
94 lines.push("StartLimitIntervalSec=30s".into());
95 lines.push(String::new());
96}
97
98fn emit_container_image(
99 lines: &mut Vec<String>,
100 config: &Config,
101 name: &str,
102 home_in_container: &str,
103 env: &HostEnv,
104) {
105 lines.push("[Container]".into());
106 if config.image.source().is_prebuilt() && config.image.packages.install.is_empty() {
107 let ref_str = match config.image.source() {
108 crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
109 _ => config.image.base.clone(),
110 };
111 lines.push(format!("Image={}", ref_str));
112 lines.push(format!("Retry={}", config.image.pull_retry));
113 lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
114 } else {
115 lines.push(format!(
116 "Image=localhost/podbox-{}:latest",
117 config.image.name
118 ));
119 }
120 lines.push(format!("ContainerName={}", name));
121 if let Some(ref mode) = config.security.userns {
122 lines.push(format!("UserNS={}", mode));
123 } else {
124 lines.push("UserNS=keep-id".into());
125 }
126 lines.push("User=root".into());
127 if config.security.security_label_disable {
128 lines.push("SecurityLabelDisable=true".into());
129 }
130 if let Some(ref seccomp) = config.security.seccomp {
131 lines.push(format!("SeccompProfile={}", seccomp));
132 }
133 if config.security.no_new_privileges {
134 lines.push("NoNewPrivileges=true".into());
135 }
136 if let Some(ref mem) = config.container.memory {
137 lines.push(format!("Memory={}", mem));
138 }
139 if let Some(ref cpus) = config.container.cpus {
140 if let Ok(v) = cpus.parse::<f64>() {
141 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
142 let quota = (v * 100_000.0) as u64;
143 lines.push(format!("CpuQuota={}", quota));
144 }
145 }
146 if config.security.read_only_rootfs {
147 lines.push("ReadOnly=true".into());
148 }
149 if let Some(ref profile) = config.security.apparmor {
150 lines.push(format!("AppArmor={}", profile));
151 }
152 lines.push(format!("Environment=HOME={}", home_in_container));
153 lines.push(format!("Environment=HOST_USER={}", env.username));
154 lines.push("Environment=HOST_UID=%U".into());
155 lines.push("Environment=HOST_GID=%G".into());
156 lines.push("Environment=PATH=/run/podbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
157 lines.push(String::new());
158}
159
160fn emit_network(lines: &mut Vec<String>, config: &Config) {
161 lines.push(format!("Network={}", config.network.mode));
162 if config.network.mode != "host" {
163 for port in &config.network.ports {
164 lines.push(format!("PublishPort={}", port));
165 }
166 }
167 lines.push(String::new());
168}
169
170fn emit_volumes(
171 lines: &mut Vec<String>,
172 config: &Config,
173 xdg: &ResolvedXdgDirs,
174 env: &HostEnv,
175 name: &str,
176 home_in_container: &str,
177) {
178 let host_home = config.container.home.to_string_lossy().to_string();
180 lines.push(format!("Volume={host_home}:{home_in_container}:Z",));
181 lines.push(String::new());
182
183 emit_xdg_dir(lines, "Documents", &xdg.documents, home_in_container);
185 emit_xdg_dir(lines, "Downloads", &xdg.downloads, home_in_container);
186 emit_xdg_dir(lines, "Pictures", &xdg.pictures, home_in_container);
187 emit_xdg_dir(lines, "Music", &xdg.music, home_in_container);
188 emit_xdg_dir(lines, "Videos", &xdg.videos, home_in_container);
189 emit_xdg_dir(lines, "Desktop", &xdg.desktop, home_in_container);
190 emit_xdg_dir(lines, "Projects", &xdg.projects, home_in_container);
191
192 if xdg.documents.is_some()
193 || xdg.downloads.is_some()
194 || xdg.pictures.is_some()
195 || xdg.music.is_some()
196 || xdg.videos.is_some()
197 || xdg.desktop.is_some()
198 || xdg.projects.is_some()
199 {
200 lines.push(String::new());
201 }
202
203 if config.integration.sync_themes {
205 let h = home();
206 if h.join(".themes").exists() {
207 lines.push(format!("Volume=%h/.themes:{home_in_container}/.themes:ro"));
208 }
209 if env.host_has_local_share_themes {
210 lines.push(format!(
211 "Volume=%h/.local/share/themes:{home_in_container}/.local/share/themes:ro"
212 ));
213 }
214 }
215 if config.integration.sync_icons {
216 let h = home();
217 if h.join(".icons").exists() {
218 lines.push(format!("Volume=%h/.icons:{home_in_container}/.icons:ro"));
219 }
220 if env.host_has_local_share_icons {
221 lines.push(format!(
222 "Volume=%h/.local/share/icons:{home_in_container}/.local/share/icons:ro"
223 ));
224 }
225 }
226 if config.integration.sync_fonts {
227 let h = home();
228 if h.join(".fonts").exists() {
229 lines.push(format!("Volume=%h/.fonts:{home_in_container}/.fonts:ro"));
230 }
231 if env.host_has_local_share_fonts {
232 lines.push(format!(
233 "Volume=%h/.local/share/fonts:{home_in_container}/.local/share/fonts:ro"
234 ));
235 }
236 }
237 if config.integration.sync_themes
238 || config.integration.sync_icons
239 || config.integration.sync_fonts
240 {
241 lines.push(String::new());
242 }
243
244 if env.host_has_localtime {
246 lines.push("Volume=/etc/localtime:/etc/localtime:ro".into());
247 }
248 if env.host_has_timezone_file {
249 lines.push("Volume=/etc/timezone:/etc/timezone:ro".into());
250 }
251 if env.host_has_localtime || env.host_has_timezone_file {
252 lines.push(String::new());
253 }
254
255 lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
258
259 if config.integration.wayland {
261 if let Some(ref display) = env.wayland_display {
262 lines.push(format!("Environment=WAYLAND_DISPLAY={}", display));
263 lines.push("Environment=MOZ_ENABLE_WAYLAND=1".into());
264 if config.wayland.firewall {
265 lines.push(format!(
266 "Volume=%t/podbox/{}-wayland.sock:%t/{}:ro",
267 name, display
268 ));
269 } else {
270 lines.push(format!("Volume=%t/{}:%t/{}:ro", display, display));
271 }
272 lines.push(String::new());
273 }
274 }
275
276 if config.integration.audio {
278 if env.pipewire_socket.is_some() {
279 lines.push("Volume=%t/pipewire-0:%t/pipewire-0".into());
280 lines.push("Environment=PIPEWIRE_RUNTIME_DIR=%t".into());
281 }
282 if env.pulse_dir.is_some() {
283 lines.push("Volume=%t/pulse:%t/pulse".into());
284 lines.push("Environment=PULSE_SERVER=unix:%t/pulse/native".into());
285 }
286 if env.pipewire_socket.is_some() || env.pulse_dir.is_some() {
287 lines.push(String::new());
288 }
289 }
290
291 if config.integration.ssh_agent {
293 let ver = crate::podman::podman_version().ok();
294 if ver.is_some_and(|v| v.at_least(5, 6) && !v.at_least(6, 0)) {
295 lines.push("SshAgent=default".into());
296 lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
297 } else {
298 eprintln!(
299 "Warning: ssh_agent = true requires Podman 5.6 - 5.x for SSH_AUTH_SOCK passthrough. Skipping SSH agent."
300 );
301 }
302 lines.push(String::new());
303 }
304
305 if config.integration.gpg_agent {
307 if let Some(ref sock) = env.gpg_agent_socket {
308 lines.push(format!(
309 "Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
310 sock.display()
311 ));
312 lines.push("Environment=GPG_TTY=/dev/pts/0".into());
313 lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
314 } else {
315 eprintln!(
316 "Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
317 );
318 }
319 lines.push(String::new());
320 }
321
322 let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
324 lines.push(format!(
325 "Volume={}:/.flatpak-info:ro",
326 flatpak_info_path.display()
327 ));
328 lines.push(String::new());
329
330 if config.integration.dbus && env.dbus_socket.is_some() {
332 if config.use_dbus_proxy() {
333 lines.push(format!(
334 "Volume=%t/podbox/{}-dbus.sock:/run/podbox/dbus.sock:ro",
335 name
336 ));
337 lines.push(
338 "Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
339 );
340 } else {
341 lines.push("Volume=%t/bus:%t/bus".into());
342 lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
343 }
344 lines.push(String::new());
345 }
346
347 lines.push(format!(
349 "Volume=%t/podbox/{}.sock:%t/podbox/{}.sock",
350 name, name
351 ));
352 lines.push(String::new());
353
354 for mount in &config.container.mounts.extra {
356 lines.push(format!("Volume={}", mount));
357 }
358 if !config.container.mounts.extra.is_empty() {
359 lines.push(String::new());
360 }
361}
362
363fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
364 if let Some(ref locale) = _env.host_locale {
366 lines.push(format!("Environment=LANG={}", locale));
367 lines.push(format!("Environment=LC_ALL={}", locale));
368 lines.push(format!("Environment=LC_CTYPE={}", locale));
369 lines.push(String::new());
370 }
371
372 for (key, value) in &config.container.env {
374 if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
375 let clean = value.replace('\n', " ").replace('\r', "");
376 let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
377 let env_val = if escaped.contains(' ') || escaped.is_empty() {
378 format!("\"{}\"", escaped)
379 } else {
380 escaped
381 };
382 lines.push(format!("Environment={}={}", key, env_val));
383 } else {
384 eprintln!(
385 "Warning: ignoring invalid environment variable key '{}'",
386 key
387 );
388 }
389 }
390 lines.push(format!("Environment=PODBOX_CONTAINER={}", name));
391 lines.push(String::new());
392}
393
394fn emit_gpu(lines: &mut Vec<String>, config: &Config, env: &HostEnv) {
395 match config.integration.gpu {
396 GpuMode::Enabled => {
397 lines.push("AddDevice=/dev/dri".into());
398 lines.push(String::new());
399 }
400 GpuMode::Nvidia => {
401 lines.push("AddDevice=/dev/dri".into());
402 lines.push("AddDevice=-/dev/nvidiactl".into());
403 lines.push("AddDevice=-/dev/nvidia0".into());
404 if env.gpu_has_nvidia_uvm {
405 lines.push("AddDevice=-/dev/nvidia-uvm".into());
406 }
407 lines.push(String::new());
408 }
409 GpuMode::Auto => {
410 if env.gpu_has_dri {
411 lines.push("AddDevice=/dev/dri".into());
412 }
413 if env.gpu_has_nvidia {
414 lines.push("AddDevice=-/dev/nvidiactl".into());
415 lines.push("AddDevice=-/dev/nvidia0".into());
416 if env.gpu_has_nvidia_uvm {
417 lines.push("AddDevice=-/dev/nvidia-uvm".into());
418 }
419 }
420 if env.gpu_has_dri || env.gpu_has_nvidia {
421 lines.push(String::new());
422 }
423 }
424 GpuMode::Disabled => {}
425 }
426}
427
428fn emit_auto_update(lines: &mut Vec<String>, config: &Config) {
429 if config.lifecycle.auto_update {
430 if config.image.source().is_prebuilt() {
431 lines.push("AutoUpdate=registry".into());
432 } else {
433 lines.push("AutoUpdate=local".into());
434 }
435 lines.push(String::new());
436 }
437}
438
439fn emit_podman_args(lines: &mut Vec<String>, config: &Config) {
440 lines.push("PodmanArgs=--init".into());
441 lines.push("PodmanArgs=--workdir=/home/%u".into());
442 let cap_preset = config.security.cap_preset;
443 let has_any_cap = !cap_preset.caps().is_empty() || !config.security.cap_add.is_empty();
444 for cap in cap_preset.caps() {
445 lines.push(format!("PodmanArgs=--cap-add={}", cap));
446 }
447 for cap in &config.security.cap_add {
448 lines.push(format!("PodmanArgs=--cap-add={}", cap));
449 }
450 if has_any_cap {
451 lines.push(String::new());
452 }
453 if let Some(ref cmd) = config.container.reload_cmd {
454 lines.push(format!("ReloadCmd={}", cmd));
455 lines.push(String::new());
456 }
457}
458
459fn emit_service_section(lines: &mut Vec<String>, config: &Config) {
460 lines.push("[Service]".into());
461 lines.push("Restart=on-failure".into());
462 lines.push("RestartSec=2s".into());
463 if config.lifecycle.on_stop == crate::config::OnStop::Remove {
464 lines.push("AutoRemove=true".into());
465 }
466 lines.push(String::new());
467}
468
469fn emit_install_section(lines: &mut Vec<String>, config: &Config) {
470 lines.push("[Install]".into());
471 if config.lifecycle.autostart {
472 lines.push("WantedBy=default.target".into());
473 }
474}
475
476pub fn generate_dbus_proxy_service(name: &str, config: &Config) -> Option<String> {
478 if !config.use_dbus_proxy() {
479 return None;
480 }
481
482 let mut args = vec![
483 "unix:path=%t/bus".to_string(),
484 format!("%t/podbox/{}-dbus.sock", name),
485 ];
486
487 args.push("--filter".into());
488
489 for service in &config.dbus_effective_talk() {
490 args.push(format!("--talk={}", service));
491 }
492 for rule in config.dbus_portal_calls() {
493 args.push(rule);
494 }
495 for service in &config.dbus.own {
496 args.push(format!("--own={}", service));
497 }
498
499 let exec_start = format!("/usr/bin/xdg-dbus-proxy {}", args.join(" "));
500
501 Some(format!(
502 r#"[Unit]
503Description=D-Bus Proxy for podbox container {name}
504PartOf={name}.service
505
506[Service]
507Type=simple
508ExecStart={exec_start}
509Restart=on-failure
510RestartSec=1s
511
512[Install]
513WantedBy={name}.service
514"#,
515 name = name,
516 exec_start = exec_start,
517 ))
518}
519
520pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
523 if !config.use_wayland_proxy() {
524 return None;
525 }
526 let podbox_bin = std::env::current_exe()
527 .map(|p| p.to_string_lossy().to_string())
528 .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
529
530 Some(format!(
531 r#"[Unit]
532Description=Wayland Firewall Proxy for podbox container {name}
533PartOf={name}.service
534
535[Service]
536Type=simple
537ExecStart={podbox_bin} compositor {name}
538Restart=on-failure
539RestartSec=1s
540
541[Install]
542WantedBy={name}.service
543"#,
544 name = name,
545 podbox_bin = podbox_bin,
546 ))
547}
548
549pub fn generate_host_service(name: &str) -> String {
551 let podbox_bin = std::env::current_exe()
552 .map(|p| p.to_string_lossy().to_string())
553 .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
554
555 format!(
556 r#"[Unit]
557Description=podbox host socket server -- {name}
558
559[Service]
560Type=simple
561ExecStart={podbox_bin} serve {name}
562Restart=on-failure
563RestartSec=2s
564
565[Install]
566WantedBy={name}.socket
567"#,
568 name = name,
569 podbox_bin = podbox_bin,
570 )
571}
572
573fn emit_xdg_dir(
574 lines: &mut Vec<String>,
575 dir_name: &str,
576 xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
577 container_home: &str,
578) {
579 if let Some(resolved) = xdg_dir {
580 let mode = if resolved.read_write { "z" } else { "ro,z" };
581 lines.push(format!(
582 "Volume={}:{container_home}/{dir_name}:{mode}",
583 resolved.path.display()
584 ));
585 }
586}