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}