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_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    // Isolated custom home
185    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    // Selective XDG dirs
190    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    // Visual integration: themes, fonts, icons
210    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    // Timezone sync
251    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    // The guest daemon needs XDG_RUNTIME_DIR to locate the host socket
262    // regardless of Wayland/audio integration.
263    lines.push("Environment=XDG_RUNTIME_DIR=%t".into());
264
265    // Wayland
266    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    // Audio (PipeWire + PulseAudio)
282    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    // SSH agent
297    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    // GPG agent
313    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    // Sandbox environment detection marker (read-only host-side kernel mount)
330    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    // D-Bus
338    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    // Host-guest socket
354    lines.push(format!(
355        "Volume=%t/podbox/{name}.sock:%t/podbox/{name}.sock"
356    ));
357    lines.push(String::new());
358
359    // Extra mounts
360    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    // Locale environment
370    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    // Extra user env
378    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
578/// Generate the companion D-Bus proxy `.service` unit.
579pub 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
620/// Generate the companion Wayland firewall `.service` unit.
621/// Returns `None` when the Wayland proxy is disabled in config.
622pub 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
647/// Generate the companion host socket server `.service` unit.
648pub 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}