Skip to main content

sysknife_daemon/actions/
toolbox.rs

1use super::{ActionMechanism, ActionSpec};
2use sysknife_types::RiskLevel;
3
4pub fn specs() -> Vec<ActionSpec> {
5    vec![
6        list_toolboxes("testuser"),
7        create_toolbox("testuser", "sysknife-dev", Some("41"), None),
8        remove_toolbox("testuser", "sysknife-dev"),
9    ]
10}
11
12/// Run a Toolbox command as the target user via `sudo runuser -l`.
13///
14/// Toolbox containers are per-user (rootless Podman under the hood). The
15/// daemon's `sysknife` system user has its own empty container store; we must
16/// switch to the correct user so toolbox reads the right storage path and
17/// sub-UID/GID ranges.
18///
19/// `XDG_RUNTIME_DIR` must be set explicitly: `runuser -l` starts a login shell
20/// but does not trigger `pam_systemd`, so `XDG_RUNTIME_DIR` is left empty.
21/// Toolbox derives its rootless Podman socket path from it; an empty value
22/// produces `--volume ":"` which Podman rejects.
23///
24/// **Shell-injection safety:** the `-c "<toolbox_cmd>"` form passes a string
25/// to `/bin/sh`, so an attacker-controlled metacharacter would be expanded.
26/// Defence-in-depth:
27///   1. `username` flows through `validated_username` (`[A-Za-z0-9._-]`).
28///   2. Toolbox `name`, `release`, and `image` flow through
29///      `validated_safe_arg`, which now enforces a strict ASCII allowlist
30///      and rejects every shell metacharacter at the boundary.
31///   3. The format!-interpolated values are wrapped in single quotes for
32///      defence-in-depth; the `$(id -u)` expansion is daemon-controlled, not
33///      attacker-reachable.
34fn toolbox_as(username: &str, toolbox_cmd: &str) -> ActionMechanism {
35    ActionMechanism::Command {
36        program: "sudo",
37        args: vec![
38            "runuser".to_string(),
39            "-l".to_string(),
40            username.to_string(),
41            "-c".to_string(),
42            format!("XDG_RUNTIME_DIR=/run/user/$(id -u) {}", toolbox_cmd),
43        ],
44    }
45}
46
47pub fn list_toolboxes(username: &str) -> ActionSpec {
48    // `toolbox list` (without --containers) lists both toolbox containers and
49    // images in a human-readable format and exits 0 even when the list is empty.
50    // --containers was dropped: it causes toolbox to probe the container runtime
51    // directly, which fails when sub-UID/GID ranges are not yet configured.
52    ActionSpec {
53        action_name: "ListToolboxes",
54        mechanism: toolbox_as(username, "toolbox list"),
55        risk_level: RiskLevel::Low,
56        reboot_required: false,
57        rollback_available: false,
58    }
59}
60
61pub fn create_toolbox(
62    username: &str,
63    name: &str,
64    release: Option<&str>,
65    image: Option<&str>,
66) -> ActionSpec {
67    let mut cmd = format!("toolbox create --container '{}'", name);
68    if let Some(release) = release {
69        cmd.push_str(&format!(" --release '{}'", release));
70    }
71    if let Some(image) = image {
72        cmd.push_str(&format!(" --image '{}'", image));
73    }
74    ActionSpec {
75        action_name: "CreateToolbox",
76        mechanism: toolbox_as(username, &cmd),
77        risk_level: RiskLevel::Medium,
78        reboot_required: false,
79        rollback_available: false,
80    }
81}
82
83pub fn remove_toolbox(username: &str, name: &str) -> ActionSpec {
84    let cmd = format!("toolbox rm '{}'", name);
85    ActionSpec {
86        action_name: "RemoveToolbox",
87        mechanism: toolbox_as(username, &cmd),
88        risk_level: RiskLevel::Medium,
89        reboot_required: false,
90        rollback_available: false,
91    }
92}