Skip to main content

podbox/codegen/
quadlet.rs

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
11/// Generate the `.build` Quadlet file.
12pub 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
27/// Generate the `.socket` Quadlet file.
28pub 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    // Keep %t/podbox alive even when no socket unit is active. Without this,
45    // systemd removes the directory when the last requesting unit stops, and
46    // a later recreation can orphan sibling containers' listening sockets.
47    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
56/// Generate the `.container` Quadlet file.
57///
58/// Pure function: all paths via HostEnv and ResolvedXdgDirs.
59pub 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_auto_update(&mut lines, config);
71    emit_podman_args(&mut lines, config);
72    emit_service_section(&mut lines, config);
73    emit_install_section(&mut lines, config);
74
75    lines.join("\n")
76}
77
78fn emit_unit(lines: &mut Vec<String>, config: &Config, name: &str) {
79    lines.push("[Unit]".into());
80    lines.push(format!("Description=podbox -- {name}"));
81    lines.push(format!("Requires={name}.socket"));
82    lines.push(format!("After={name}.socket"));
83    for dep in &config.systemd.requires {
84        lines.push(format!("Requires={dep}"));
85    }
86    for dep in &config.systemd.after {
87        lines.push(format!("After={dep}"));
88    }
89    if config.use_dbus_proxy() {
90        lines.push(format!("Requires={name}-proxy.service"));
91        lines.push(format!("After={name}-proxy.service"));
92    }
93    if config.use_wayland_proxy() {
94        lines.push(format!("Requires={name}-compositor.service"));
95        lines.push(format!("After={name}-compositor.service"));
96    }
97    lines.push("StartLimitBurst=5".into());
98    lines.push("StartLimitIntervalSec=30s".into());
99    lines.push(String::new());
100}
101
102fn emit_container_image(
103    lines: &mut Vec<String>,
104    config: &Config,
105    name: &str,
106    home_in_container: &str,
107    env: &HostEnv,
108) {
109    lines.push("[Container]".into());
110    if config.image.source().is_prebuilt() && config.image.packages.install.is_empty() {
111        let ref_str = match config.image.source() {
112            crate::config::ImageSource::Prebuilt { ref_str } => ref_str,
113            _ => config.image.base.clone(),
114        };
115        lines.push(format!("Image={ref_str}"));
116        lines.push(format!("Retry={}", config.image.pull_retry));
117        lines.push(format!("RetryDelay={}", config.image.pull_retry_delay));
118    } else {
119        lines.push(format!(
120            "Image=localhost/podbox-{}:latest",
121            config.image.name
122        ));
123    }
124    lines.push(format!("ContainerName={name}"));
125    if let Some(ref mode) = config.security.userns {
126        lines.push(format!("UserNS={mode}"));
127    } else {
128        lines.push("UserNS=keep-id".into());
129    }
130    lines.push("User=root".into());
131    if config.security.security_label_disable {
132        lines.push("SecurityLabelDisable=true".into());
133    }
134    if let Some(ref seccomp) = config.security.seccomp {
135        lines.push(format!("SeccompProfile={seccomp}"));
136    }
137    if config.security.no_new_privileges {
138        lines.push("NoNewPrivileges=true".into());
139    }
140    if let Some(ref mem) = config.container.memory {
141        lines.push(format!("Memory={mem}"));
142    }
143    if let Some(ref cpus) = config.container.cpus {
144        if let Ok(v) = cpus.parse::<f64>() {
145            #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)]
146            let quota = (v * 100_000.0) as u64;
147            lines.push(format!("CpuQuota={quota}"));
148        }
149    }
150    if config.security.read_only_rootfs {
151        lines.push("ReadOnly=true".into());
152    }
153    if let Some(ref profile) = config.security.apparmor {
154        lines.push(format!("AppArmor={profile}"));
155    }
156    lines.push(format!("Environment=HOME={home_in_container}"));
157    lines.push(format!("Environment=HOST_USER={}", env.username));
158    lines.push("Environment=HOST_UID=%U".into());
159    lines.push("Environment=HOST_GID=%G".into());
160    lines.push("Environment=PATH=/run/podbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin".into());
161    lines.push(String::new());
162}
163
164fn emit_network(lines: &mut Vec<String>, config: &Config) {
165    lines.push(format!("Network={}", config.network.mode));
166    if config.network.mode != "host" {
167        for port in &config.network.ports {
168            lines.push(format!("PublishPort={port}"));
169        }
170    }
171    lines.push(String::new());
172}
173
174fn emit_volumes(
175    lines: &mut Vec<String>,
176    config: &Config,
177    xdg: &ResolvedXdgDirs,
178    env: &HostEnv,
179    name: &str,
180    home_in_container: &str,
181) {
182    // Isolated custom home
183    let host_home = config.container.home.to_string_lossy().to_string();
184    lines.push(format!("Volume={host_home}:{home_in_container}:Z",));
185    lines.push(String::new());
186
187    // Selective XDG dirs
188    emit_xdg_dir(lines, "Documents", &xdg.documents, home_in_container);
189    emit_xdg_dir(lines, "Downloads", &xdg.downloads, home_in_container);
190    emit_xdg_dir(lines, "Pictures", &xdg.pictures, home_in_container);
191    emit_xdg_dir(lines, "Music", &xdg.music, home_in_container);
192    emit_xdg_dir(lines, "Videos", &xdg.videos, home_in_container);
193    emit_xdg_dir(lines, "Desktop", &xdg.desktop, home_in_container);
194    emit_xdg_dir(lines, "Projects", &xdg.projects, home_in_container);
195
196    if xdg.documents.is_some()
197        || xdg.downloads.is_some()
198        || xdg.pictures.is_some()
199        || xdg.music.is_some()
200        || xdg.videos.is_some()
201        || xdg.desktop.is_some()
202        || xdg.projects.is_some()
203    {
204        lines.push(String::new());
205    }
206
207    // Visual integration: themes, fonts, icons
208    if config.integration.sync_themes {
209        let h = home();
210        if h.join(".themes").exists() {
211            lines.push(format!("Volume=%h/.themes:{home_in_container}/.themes:ro"));
212        }
213        if env.host_has_local_share_themes {
214            lines.push(format!(
215                "Volume=%h/.local/share/themes:{home_in_container}/.local/share/themes:ro"
216            ));
217        }
218    }
219    if config.integration.sync_icons {
220        let h = home();
221        if h.join(".icons").exists() {
222            lines.push(format!("Volume=%h/.icons:{home_in_container}/.icons:ro"));
223        }
224        if env.host_has_local_share_icons {
225            lines.push(format!(
226                "Volume=%h/.local/share/icons:{home_in_container}/.local/share/icons:ro"
227            ));
228        }
229    }
230    if config.integration.sync_fonts {
231        let h = home();
232        if h.join(".fonts").exists() {
233            lines.push(format!("Volume=%h/.fonts:{home_in_container}/.fonts:ro"));
234        }
235        if env.host_has_local_share_fonts {
236            lines.push(format!(
237                "Volume=%h/.local/share/fonts:{home_in_container}/.local/share/fonts:ro"
238            ));
239        }
240    }
241    if config.integration.sync_themes
242        || config.integration.sync_icons
243        || config.integration.sync_fonts
244    {
245        lines.push(String::new());
246    }
247
248    // Timezone sync
249    if env.host_has_localtime {
250        lines.push("Volume=/etc/localtime:/etc/localtime:ro".into());
251    }
252    if env.host_has_timezone_file {
253        lines.push("Volume=/etc/timezone:/etc/timezone:ro".into());
254    }
255    if env.host_has_localtime || env.host_has_timezone_file {
256        lines.push(String::new());
257    }
258
259    // The guest daemon needs XDG_RUNTIME_DIR to locate the host socket
260    // regardless of Wayland/audio integration.
261    lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
262
263    // Wayland
264    if config.integration.wayland {
265        if let Some(ref display) = env.wayland_display {
266            lines.push(format!("Environment=WAYLAND_DISPLAY={display}"));
267            lines.push("Environment=MOZ_ENABLE_WAYLAND=1".into());
268            if config.wayland.firewall {
269                lines.push(format!(
270                    "Volume=%t/podbox/{name}-wayland.sock:%t/{display}:ro"
271                ));
272            } else {
273                lines.push(format!("Volume=%t/{display}:%t/{display}:ro"));
274            }
275            lines.push(String::new());
276        }
277    }
278
279    // Audio (PipeWire + PulseAudio)
280    if config.integration.audio {
281        if env.pipewire_socket.is_some() {
282            lines.push("Volume=%t/pipewire-0:%t/pipewire-0".into());
283            lines.push("Environment=PIPEWIRE_RUNTIME_DIR=%t".into());
284        }
285        if env.pulse_dir.is_some() {
286            lines.push("Volume=%t/pulse:%t/pulse".into());
287            lines.push("Environment=PULSE_SERVER=unix:%t/pulse/native".into());
288        }
289        if env.pipewire_socket.is_some() || env.pulse_dir.is_some() {
290            lines.push(String::new());
291        }
292    }
293
294    // SSH agent
295    if config.integration.ssh_agent {
296        if let Some(ref sock) = env.ssh_agent_socket {
297            lines.push(format!(
298                "Volume={}:/run/podbox/ssh-agent.sock",
299                sock.display()
300            ));
301            lines.push("Environment=SSH_AUTH_SOCK=/run/podbox/ssh-agent.sock".into());
302        } else {
303            eprintln!(
304                "Warning: ssh_agent = true but SSH_AUTH_SOCK not found on host. Skipping SSH agent."
305            );
306        }
307        lines.push(String::new());
308    }
309
310    // GPG agent
311    if config.integration.gpg_agent {
312        if let Some(ref sock) = env.gpg_agent_socket {
313            lines.push(format!(
314                "Volume={}:/run/podbox/gnupg/S.gpg-agent:ro",
315                sock.display()
316            ));
317            lines.push("Environment=GPG_TTY=/dev/pts/0".into());
318            lines.push("Environment=GNUPGHOME=/run/podbox/gnupg".into());
319        } else {
320            eprintln!(
321                "Warning: gpg_agent = true but S.gpg-agent socket not found on host. Skipping GPG agent."
322            );
323        }
324        lines.push(String::new());
325    }
326
327    // Sandbox environment detection marker (read-only host-side kernel mount)
328    let flatpak_info_path = crate::build::build_context_dir(name).join(".flatpak-info");
329    lines.push(format!(
330        "Volume={}:/.flatpak-info:ro",
331        flatpak_info_path.display()
332    ));
333    lines.push(String::new());
334
335    // D-Bus
336    if config.integration.dbus && env.dbus_socket.is_some() {
337        if config.use_dbus_proxy() {
338            lines.push(format!(
339                "Volume=%t/podbox/{name}-dbus.sock:/run/podbox/dbus.sock:ro"
340            ));
341            lines.push(
342                "Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=/run/podbox/dbus.sock".into(),
343            );
344        } else {
345            lines.push("Volume=%t/bus:%t/bus".into());
346            lines.push("Environment=DBUS_SESSION_BUS_ADDRESS=unix:path=%t/bus".into());
347        }
348        lines.push(String::new());
349    }
350
351    // Host-guest socket
352    lines.push(format!(
353        "Volume=%t/podbox/{name}.sock:%t/podbox/{name}.sock"
354    ));
355    lines.push(String::new());
356
357    // Extra mounts
358    for mount in &config.container.mounts.extra {
359        lines.push(format!("Volume={mount}"));
360    }
361    if !config.container.mounts.extra.is_empty() {
362        lines.push(String::new());
363    }
364}
365
366fn emit_env(lines: &mut Vec<String>, config: &Config, name: &str, _env: &HostEnv) {
367    // Locale environment
368    if let Some(ref locale) = _env.host_locale {
369        lines.push(format!("Environment=LANG={locale}"));
370        lines.push(format!("Environment=LC_ALL={locale}"));
371        lines.push(format!("Environment=LC_CTYPE={locale}"));
372        lines.push(String::new());
373    }
374
375    // Extra user env
376    for (key, value) in &config.container.env {
377        if key.chars().all(|c| c.is_alphanumeric() || c == '_') {
378            let clean = value.replace('\n', " ").replace('\r', "");
379            let escaped = clean.replace('\\', "\\\\").replace('"', "\\\"");
380            let env_val = if escaped.contains(' ') || escaped.is_empty() {
381                format!("\"{escaped}\"")
382            } else {
383                escaped
384            };
385            lines.push(format!("Environment={key}={env_val}"));
386        } else {
387            eprintln!("Warning: ignoring invalid environment variable key '{key}'");
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
476/// Generate the companion D-Bus proxy `.service` unit.
477pub 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    ))
516}
517
518/// Generate the companion Wayland firewall `.service` unit.
519/// Returns `None` when the Wayland proxy is disabled in config.
520pub fn generate_compositor_service(name: &str, config: &Config) -> Option<String> {
521    if !config.use_wayland_proxy() {
522        return None;
523    }
524    let podbox_bin = std::env::current_exe()
525        .map(|p| p.to_string_lossy().to_string())
526        .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
527
528    Some(format!(
529        r#"[Unit]
530Description=Wayland Firewall Proxy for podbox container {name}
531PartOf={name}.service
532
533[Service]
534Type=simple
535ExecStart={podbox_bin} compositor {name}
536Restart=on-failure
537RestartSec=1s
538
539[Install]
540WantedBy={name}.service
541"#,
542    ))
543}
544
545/// Generate the companion host socket server `.service` unit.
546pub fn generate_host_service(name: &str) -> String {
547    let podbox_bin = std::env::current_exe()
548        .map(|p| p.to_string_lossy().to_string())
549        .unwrap_or_else(|_| "/usr/local/bin/podbox".into());
550
551    format!(
552        r#"[Unit]
553Description=podbox host socket server -- {name}
554
555[Service]
556Type=simple
557ExecStart={podbox_bin} serve {name}
558Restart=on-failure
559RestartSec=2s
560
561[Install]
562WantedBy={name}.socket
563"#,
564    )
565}
566
567fn emit_xdg_dir(
568    lines: &mut Vec<String>,
569    dir_name: &str,
570    xdg_dir: &Option<crate::xdg::ResolvedXdgDir>,
571    container_home: &str,
572) {
573    if let Some(resolved) = xdg_dir {
574        let mode = if resolved.read_write { "z" } else { "ro,z" };
575        lines.push(format!(
576            "Volume={}:{container_home}/{dir_name}:{mode}",
577            resolved.path.display()
578        ));
579    }
580}