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!("{name}-host.service");
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/{name}.sock"));
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("RuntimeDirectoryPreserve=yes".into());
48 lines.push(String::new());
49
50 lines.push("[Install]".into());
51 lines.push("WantedBy=sockets.target".into());
52
53 lines.join("\n")
54}
55
56pub fn generate_container(config: &Config, env: &HostEnv, xdg: &ResolvedXdgDirs) -> String {
60 let name = &config.container.name;
61 let home_in_container = "/home/%u";
62 let mut lines: Vec<String> = Vec::new();
63
64 emit_unit(&mut lines, config, name);
65 emit_container_image(&mut lines, config, name, home_in_container, env);
66 emit_network(&mut lines, config);
67 emit_volumes(&mut lines, config, xdg, env, name, home_in_container);
68 emit_env(&mut lines, config, name, env);
69 emit_gpu(&mut lines, config, env);
70 emit_hardware_devices(&mut lines, config);
71 emit_secrets(&mut lines, config);
72 emit_auto_update(&mut lines, config);
73 emit_podman_args(&mut lines, config);
74 emit_service_section(&mut lines, config);
75 emit_install_section(&mut lines, config);
76
77 lines.join("\n")
78}
79
80fn emit_unit(lines: &mut Vec<String>, config: &Config, name: &str) {
81 lines.push("[Unit]".into());
82 lines.push(format!("Description=podbox -- {name}"));
83 lines.push(format!("Requires={name}.socket"));
84 lines.push(format!("After={name}.socket"));
85 for dep in &config.systemd.requires {
86 lines.push(format!("Requires={dep}"));
87 }
88 for dep in &config.systemd.after {
89 lines.push(format!("After={dep}"));
90 }
91 if config.use_dbus_proxy() {
92 lines.push(format!("Requires={name}-proxy.service"));
93 lines.push(format!("After={name}-proxy.service"));
94 }
95 if config.use_wayland_proxy() {
96 lines.push(format!("Requires={name}-compositor.service"));
97 lines.push(format!("After={name}-compositor.service"));
98 }
99 lines.push("StartLimitBurst=5".into());
100 lines.push("StartLimitIntervalSec=30s".into());
101 lines.push(String::new());
102}
103
104fn emit_container_image(
105 lines: &mut Vec<String>,
106 config: &Config,
107 name: &str,
108 home_in_container: &str,
109 env: &HostEnv,
110) {
111 lines.push("[Container]".into());
112 if config.image.source().is_prebuilt() && config.image.packages.install.is_empty() {
113 let ref_str = match config.image.source() {
114 crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
115 _ => config.image.base.clone(),
116 };
117 lines.push(format!("Image={ref_str}"));
118 lines.push(format!("Retry={}", config.image.pull_retry));
119 lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
120 } else {
121 lines.push(format!(
122 "Image=localhost/podbox-{}:latest",
123 config.image.name
124 ));
125 }
126 lines.push(format!("ContainerName={name}"));
127 if let Some(ref mode) = config.security.userns {
128 lines.push(format!("UserNS={mode}"));
129 } else {
130 lines.push("UserNS=keep-id".into());
131 }
132 lines.push("User=root".into());
133 if config.security.security_label_disable {
134 lines.push("SecurityLabelDisable=true".into());
135 }
136 if let Some(ref seccomp) = config.security.seccomp {
137 lines.push(format!("SeccompProfile={seccomp}"));
138 }
139 if config.security.no_new_privileges {
140 lines.push("NoNewPrivileges=true".into());
141 }
142 if let Some(ref mem) = config.container.memory {
143 lines.push(format!("Memory={mem}"));
144 }
145 if let Some(ref cpus) = config.container.cpus {
146 if let Ok(v) = cpus.parse::<f64>() {
147 #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
148 let quota = (v * 100_000.0) as u64;
149 lines.push(format!("CpuQuota={quota}"));
150 }
151 }
152 if config.security.read_only_rootfs {
153 lines.push("ReadOnly=true".into());
154 }
155 if let Some(ref profile) = config.security.apparmor {
156 lines.push(format!("AppArmor={profile}"));
157 }
158 lines.push(format!("Environment=HOME={home_in_container}"));
159 lines.push(format!("Environment=HOST_USER={}", env.username));
160 lines.push("Environment=HOST_UID=%U".into());
161 lines.push("Environment=HOST_GID=%G".into());
162 lines.push("Environment=PATH=/run/podbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
163 lines.push(String::new());
164}
165
166fn emit_network(lines: &mut Vec<String>, config: &Config) {
167 lines.push(format!("Network={}", config.network.mode));
168 if config.network.mode != "host" {
169 for port in &config.network.ports {
170 lines.push(format!("PublishPort={port}"));
171 }
172 }
173 lines.push(String::new());
174}
175
176fn emit_volumes(
177 lines: &mut Vec<String>,
178 config: &Config,
179 xdg: &ResolvedXdgDirs,
180 env: &HostEnv,
181 name: &str,
182 home_in_container: &str,
183) {
184 let host_home = config.container.home.to_string_lossy().to_string();
186 lines.push(format!("Volume={host_home}:{home_in_container}:Z",));
187 lines.push(String::new());
188
189 emit_xdg_dir(lines, "Documents", &xdg.documents, home_in_container);
191 emit_xdg_dir(lines, "Downloads", &xdg.downloads, home_in_container);
192 emit_xdg_dir(lines, "Pictures", &xdg.pictures, home_in_container);
193 emit_xdg_dir(lines, "Music", &xdg.music, home_in_container);
194 emit_xdg_dir(lines, "Videos", &xdg.videos, home_in_container);
195 emit_xdg_dir(lines, "Desktop", &xdg.desktop, home_in_container);
196 emit_xdg_dir(lines, "Projects", &xdg.projects, home_in_container);
197
198 if xdg.documents.is_some()
199 || xdg.downloads.is_some()
200 || xdg.pictures.is_some()
201 || xdg.music.is_some()
202 || xdg.videos.is_some()
203 || xdg.desktop.is_some()
204 || xdg.projects.is_some()
205 {
206 lines.push(String::new());
207 }
208
209 if config.integration.sync_themes {
211 let h = home();
212 if h.join(".themes").exists() {
213 lines.push(format!("Volume=%h/.themes:{home_in_container}/.themes:ro"));
214 }
215 if env.host_has_local_share_themes {
216 lines.push(format!(
217 "Volume=%h/.local/share/themes:{home_in_container}/.local/share/themes:ro"
218 ));
219 }
220 }
221 if config.integration.sync_icons {
222 let h = home();
223 if h.join(".icons").exists() {
224 lines.push(format!("Volume=%h/.icons:{home_in_container}/.icons:ro"));
225 }
226 if env.host_has_local_share_icons {
227 lines.push(format!(
228 "Volume=%h/.local/share/icons:{home_in_container}/.local/share/icons:ro"
229 ));
230 }
231 }
232 if config.integration.sync_fonts {
233 let h = home();
234 if h.join(".fonts").exists() {
235 lines.push(format!("Volume=%h/.fonts:{home_in_container}/.fonts:ro"));
236 }
237 if env.host_has_local_share_fonts {
238 lines.push(format!(
239 "Volume=%h/.local/share/fonts:{home_in_container}/.local/share/fonts:ro"
240 ));
241 }
242 }
243 if config.integration.sync_themes
244 || config.integration.sync_icons
245 || config.integration.sync_fonts
246 {
247 lines.push(String::new());
248 }
249
250 if env.host_has_localtime {
252 lines.push("Volume=/etc/localtime:/etc/localtime:ro".into());
253 }
254 if env.host_has_timezone_file {
255 lines.push("Volume=/etc/timezone:/etc/timezone:ro".into());
256 }
257 if env.host_has_localtime || env.host_has_timezone_file {
258 lines.push(String::new());
259 }
260
261 lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
264
265 if config.integration.wayland {
267 if let Some(ref display) = env.wayland_display {
268 lines.push(format!("Environment=WAYLAND_DISPLAY={display}"));
269 lines.push("Environment=MOZ_ENABLE_WAYLAND=1".into());
270 if config.wayland.firewall {
271 lines.push(format!(
272 "Volume=%t/podbox/{name}-wayland.sock:%t/{display}:ro"
273 ));
274 } else {
275 lines.push(format!("Volume=%t/{display}:%t/{display}:ro"));
276 }
277 lines.push(String::new());
278 }
279 }
280
281 if config.integration.audio {
283 if env.pipewire_socket.is_some() {
284 lines.push("Volume=%t/pipewire-0:%t/pipewire-0".into());
285 lines.push("Environment=PIPEWIRE_RUNTIME_DIR=%t".into());
286 }
287 if env.pulse_dir.is_some() {
288 lines.push("Volume=%t/pulse:%t/pulse".into());
289 lines.push("Environment=PULSE_SERVER=unix:%t/pulse/native".into());
290 }
291 if env.pipewire_socket.is_some() || env.pulse_dir.is_some() {
292 lines.push(String::new());
293 }
294 }
295
296 if config.integration.ssh_agent {
298 if let Some(ref sock) = env.ssh_agent_socket {
299 lines.push(format!(
300 "Volume={}:/run/podbox/ssh-agent.sock",
301 sock.display()
302 ));
303 lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
304 } else {
305 eprintln!(
306 "Warning: ssh_agent = true but SSH_AUTH_SOCK not found on host. Skipping SSH agent."
307 );
308 }
309 lines.push(String::new());
310 }
311
312 if config.integration.gpg_agent {
314 if let Some(ref sock) = env.gpg_agent_socket {
315 lines.push(format!(
316 "Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
317 sock.display()
318 ));
319 lines.push("Environment=GPG_TTY=/dev/pts/0".into());
320 lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
321 } else {
322 eprintln!(
323 "Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
324 );
325 }
326 lines.push(String::new());
327 }
328
329 let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
331 lines.push(format!(
332 "Volume={}:/.flatpak-info:ro",
333 flatpak_info_path.display()
334 ));
335 lines.push(String::new());
336
337 if config.integration.dbus && env.dbus_socket.is_some() {
339 if config.use_dbus_proxy() {
340 lines.push(format!(
341 "Volume=%t/podbox/{name}-dbus.sock:/run/podbox/dbus.sock:ro"
342 ));
343 lines.push(
344 "Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
345 );
346 } else {
347 lines.push("Volume=%t/bus:%t/bus".into());
348 lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
349 }
350 lines.push(String::new());
351 }
352
353 lines.push(format!(
355 "Volume=%t/podbox/{name}.sock:%t/podbox/{name}.sock"
356 ));
357 lines.push(String::new());
358
359 for mount in &config.container.mounts.extra {
361 lines.push(format!("Volume={mount}"));
362 }
363 if !config.container.mounts.extra.is_empty() {
364 lines.push(String::new());
365 }
366}
367
368fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
369 if let Some(ref locale) = _env.host_locale {
371 lines.push(format!("Environment=LANG={locale}"));
372 lines.push(format!("Environment=LC_ALL={locale}"));
373 lines.push(format!("Environment=LC_CTYPE={locale}"));
374 lines.push(String::new());
375 }
376
377 for (key, value) in &config.container.env {
379 if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
380 let clean = value.replace('\n', " ").replace('\r', "");
381 let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
382 let env_val = if escaped.contains(' ') || escaped.is_empty() {
383 format!("\"{escaped}\"")
384 } else {
385 escaped
386 };
387 lines.push(format!("Environment={key}={env_val}"));
388 } else {
389 eprintln!("Warning: ignoring invalid environment variable key '{key}'");
390 }
391 }
392 lines.push(format!("Environment=PODBOX_CONTAINER={name}"));
393 lines.push(String::new());
394}
395
396fn emit_gpu(lines: &mut Vec<String>, config: &Config, env: &HostEnv) {
397 match config.integration.gpu {
398 GpuMode::Enabled => {
399 lines.push("AddDevice=/dev/dri".into());
400 lines.push(String::new());
401 }
402 GpuMode::Nvidia => {
403 lines.push("AddDevice=/dev/dri".into());
404 lines.push("AddDevice=-/dev/nvidiactl".into());
405 lines.push("AddDevice=-/dev/nvidia0".into());
406 if env.gpu_has_nvidia_uvm {
407 lines.push("AddDevice=-/dev/nvidia-uvm".into());
408 }
409 lines.push(String::new());
410 }
411 GpuMode::Auto => {
412 if env.gpu_has_dri {
413 lines.push("AddDevice=/dev/dri".into());
414 }
415 if env.gpu_has_nvidia {
416 lines.push("AddDevice=-/dev/nvidiactl".into());
417 lines.push("AddDevice=-/dev/nvidia0".into());
418 if env.gpu_has_nvidia_uvm {
419 lines.push("AddDevice=-/dev/nvidia-uvm".into());
420 }
421 }
422 if env.gpu_has_dri || env.gpu_has_nvidia {
423 lines.push(String::new());
424 }
425 }
426 GpuMode::Disabled => {}
427 }
428}
429
430pub fn emit_hardware_devices(lines: &mut Vec<String>, config: &Config) {
431 let hw = &config.integration.hardware;
432
433 let mut emitted = false;
434
435 if hw.kvm {
436 lines.push("AddDevice=-/dev/kvm".into());
437 emitted = true;
438 }
439
440 if hw.joystick {
441 lines.push("AddDevice=-/dev/uinput".into());
442 lines.push("AddDevice=-/dev/input".into());
443 emitted = true;
444 }
445
446 if hw.webcam {
447 for i in 0..16 {
448 lines.push(format!("AddDevice=-/dev/video{i}"));
449 lines.push(format!("AddDevice=-/dev/media{i}"));
450 }
451 emitted = true;
452 }
453
454 if hw.serial {
455 for i in 0..8 {
456 lines.push(format!("AddDevice=-/dev/ttyUSB{i}"));
457 lines.push(format!("AddDevice=-/dev/ttyACM{i}"));
458 }
459 emitted = true;
460 }
461
462 if hw.yubikey {
463 lines.push("Volume=-%t/pcscd/pcscd.comm:/run/pcscd/pcscd.comm:ro".into());
464 for i in 0..16 {
465 lines.push(format!("AddDevice=-/dev/hidraw{i}"));
466 }
467 emitted = true;
468 }
469
470 if emitted {
471 lines.push(String::new());
472 }
473}
474
475pub fn emit_secrets(lines: &mut Vec<String>, config: &Config) {
476 use crate::config::{SecretEntry, SecretSource, SecretType};
477
478 let mut emitted = false;
479 for secret in &config.security.secrets {
480 emitted = true;
481 match secret {
482 SecretEntry::Simple(name) => {
483 lines.push(format!("Secret={name},type=env,target={name}"));
484 }
485 SecretEntry::Detailed {
486 name,
487 secret_type,
488 target,
489 mode,
490 source,
491 } => match source {
492 SecretSource::Podman => {
493 let mut opts = vec![name.clone()];
494 match secret_type {
495 SecretType::Env => {
496 opts.push("type=env".into());
497 if let Some(t) = target {
498 opts.push(format!("target={t}"));
499 }
500 }
501 SecretType::Mount => {
502 opts.push("type=mount".into());
503 if let Some(t) = target {
504 opts.push(format!("target={t}"));
505 }
506 if let Some(m) = mode {
507 opts.push(format!("mode={m}"));
508 }
509 opts.push("uid=%U".into());
510 opts.push("gid=%G".into());
511 }
512 }
513 lines.push(format!("Secret={}", opts.join(",")));
514 }
515 SecretSource::Systemd => {
516 lines.push(format!(
517 "Environment={}=%d/{}",
518 target.as_deref().unwrap_or(name),
519 name
520 ));
521 }
522 },
523 }
524 }
525 if emitted {
526 lines.push(String::new());
527 }
528}
529
530fn emit_auto_update(lines: &mut Vec<String>, config: &Config) {
531 if config.lifecycle.auto_update {
532 if config.image.source().is_prebuilt() {
533 lines.push("AutoUpdate=registry".into());
534 } else {
535 lines.push("AutoUpdate=local".into());
536 }
537 lines.push(String::new());
538 }
539}
540
541fn emit_podman_args(lines: &mut Vec<String>, config: &Config) {
542 lines.push("PodmanArgs=--init".into());
543 lines.push("PodmanArgs=--workdir=/home/%u".into());
544 let cap_preset = config.security.cap_preset;
545 let has_any_cap = !cap_preset.caps().is_empty() || !config.security.cap_add.is_empty();
546 for cap in cap_preset.caps() {
547 lines.push(format!("PodmanArgs=--cap-add={cap}"));
548 }
549 for cap in &config.security.cap_add {
550 lines.push(format!("PodmanArgs=--cap-add={cap}"));
551 }
552 if has_any_cap {
553 lines.push(String::new());
554 }
555 if let Some(ref cmd) = config.container.reload_cmd {
556 lines.push(format!("ReloadCmd={cmd}"));
557 lines.push(String::new());
558 }
559}
560
561fn emit_service_section(lines: &mut Vec<String>, config: &Config) {
562 lines.push("[Service]".into());
563 lines.push("Restart=on-failure".into());
564 lines.push("RestartSec=2s".into());
565 if config.lifecycle.on_stop == crate::config::OnStop::Remove {
566 lines.push("AutoRemove=true".into());
567 }
568 lines.push(String::new());
569}
570
571fn emit_install_section(lines: &mut Vec<String>, config: &Config) {
572 lines.push("[Install]".into());
573 if config.lifecycle.autostart {
574 lines.push("WantedBy=default.target".into());
575 }
576}
577
578pub fn generate_dbus_proxy_service(name: &str, config: &Config) -> Option<String> {
580 if !config.use_dbus_proxy() {
581 return None;
582 }
583
584 let mut args = vec![
585 "unix:path=%t/bus".to_string(),
586 format!("%t/podbox/{}-dbus.sock", name),
587 ];
588
589 args.push("--filter".into());
590
591 for service in &config.dbus_effective_talk() {
592 args.push(format!("--talk={service}"));
593 }
594 for rule in config.dbus_portal_calls() {
595 args.push(rule);
596 }
597 for service in &config.dbus.own {
598 args.push(format!("--own={service}"));
599 }
600
601 let exec_start = format!("/usr/bin/xdg-dbus-proxy {}", args.join(" "));
602
603 Some(format!(
604 r#"[Unit]
605Description=D-Bus Proxy for podbox container {name}
606PartOf={name}.service
607
608[Service]
609Type=simple
610ExecStart={exec_start}
611Restart=on-failure
612RestartSec=1s
613
614[Install]
615WantedBy={name}.service
616"#,
617 ))
618}
619
620pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
623 if !config.use_wayland_proxy() {
624 return None;
625 }
626 let podbox_bin = std::env::current_exe()
627 .map(|p| p.to_string_lossy().to_string())
628 .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
629
630 Some(format!(
631 r#"[Unit]
632Description=Wayland Firewall Proxy for podbox container {name}
633PartOf={name}.service
634
635[Service]
636Type=simple
637ExecStart={podbox_bin} compositor {name}
638Restart=on-failure
639RestartSec=1s
640
641[Install]
642WantedBy={name}.service
643"#,
644 ))
645}
646
647pub fn generate_host_service(name: &str) -> String {
649 let podbox_bin = std::env::current_exe()
650 .map(|p| p.to_string_lossy().to_string())
651 .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
652
653 format!(
654 r#"[Unit]
655Description=podbox host socket server -- {name}
656
657[Service]
658Type=simple
659ExecStart={podbox_bin} serve {name}
660Restart=on-failure
661RestartSec=2s
662
663[Install]
664WantedBy={name}.socket
665"#,
666 )
667}
668
669fn emit_xdg_dir(
670 lines: &mut Vec<String>,
671 dir_name: &str,
672 xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
673 container_home: &str,
674) {
675 if let Some(resolved) = xdg_dir {
676 let mode = if resolved.read_write { "z" } else { "ro,z" };
677 lines.push(format!(
678 "Volume={}:{container_home}/{dir_name}:{mode}",
679 resolved.path.display()
680 ));
681 }
682}