Skip to main content

sysknife_daemon/actions/
mod.rs

1use sysknife_types::RiskLevel;
2
3pub mod apparmor;
4pub mod apt;
5pub mod apt_preferences;
6pub mod auditd;
7pub mod certbot;
8pub mod cloudinit;
9pub mod containers;
10pub mod deployment;
11pub mod distrobox;
12pub mod fail2ban;
13pub mod filesystem;
14pub mod flatpak;
15pub mod grub;
16pub mod identity;
17pub mod journald;
18pub mod layering;
19pub mod livepatch;
20pub mod logging;
21pub mod lvm;
22pub mod mounts;
23pub mod multipass;
24pub mod netplan;
25pub mod network;
26pub mod package_repos;
27pub mod pam;
28pub mod ppa;
29pub mod processes;
30pub mod reboot;
31pub mod release_upgrade;
32pub mod resolvectl;
33pub mod services;
34pub mod snap;
35pub mod ssh;
36pub mod sudoers;
37pub mod sysctl;
38pub mod system_info;
39pub mod toolbox;
40pub mod ubuntu_pro;
41pub mod ufw;
42pub mod users;
43pub(crate) mod validate;
44
45/// Observer-callable actions that still mutate the host and therefore require
46/// the preview/approve/execute interlock.
47///
48/// This is shared by every approval-free surface. Keeping the exception beside
49/// the action catalogue prevents the CLI's generated MCP router and the
50/// daemon's `query_action` endpoint from making independent safety decisions.
51pub const OBSERVER_MUTATING_ACTIONS: &[&str] = &["AptUpdate"];
52
53#[derive(Clone, Debug, PartialEq, Eq)]
54pub enum ActionMechanism {
55    Command {
56        program: &'static str,
57        args: Vec<String>,
58    },
59    FileScan {
60        path: String,
61    },
62    FileWrite {
63        path: String,
64        content: String,
65    },
66    FilePatch {
67        path: String,
68        search: String,
69        replace: String,
70    },
71    FileDelete {
72        path: String,
73    },
74}
75
76#[derive(Clone, Debug, PartialEq, Eq)]
77pub struct ActionSpec {
78    pub action_name: &'static str,
79    pub mechanism: ActionMechanism,
80    pub risk_level: RiskLevel,
81    pub reboot_required: bool,
82    pub rollback_available: bool,
83}
84
85/// A system-wide lock that only one mutating action may hold at a time.
86///
87/// These are the real kernel/daemon locks that make two concurrent actions
88/// fail rather than merely interleave: `apt`/`dpkg` serialise on
89/// `/var/lib/dpkg/lock-frontend`, `snapd` on its change queue, `rpm-ostree`
90/// on its transaction lock. Two actions contending for the same resource do
91/// not produce a partial result — one of them errors out mid-way, which for a
92/// package operation can leave dpkg needing `--configure -a`.
93#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
94pub enum ExclusiveResource {
95    /// The whole machine. Claimed by any High-risk reboot-required action
96    /// (release upgrade, rebase, layered-package install), which conflicts
97    /// with every other mutating action regardless of resource.
98    System,
99    /// The dpkg/apt database lock. Ubuntu/Debian package operations.
100    Dpkg,
101    /// The snapd change queue.
102    Snap,
103    /// The rpm-ostree transaction lock.
104    RpmOstree,
105    /// The flatpak installation lock.
106    Flatpak,
107}
108
109impl ExclusiveResource {
110    /// Human-readable name used in the conflict message shown to the operator.
111    pub fn label(self) -> &'static str {
112        match self {
113            Self::System => "the system (a reboot-required action)",
114            Self::Dpkg => "the dpkg/apt lock",
115            Self::Snap => "the snapd change queue",
116            Self::RpmOstree => "the rpm-ostree transaction lock",
117            Self::Flatpak => "the flatpak installation lock",
118        }
119    }
120}
121
122/// Which system lock, if any, this action contends for.
123///
124/// Derived from the action's own argv rather than a hand-maintained action
125/// list, so a newly added `apt-get` action participates in the gate without
126/// anyone remembering to register it. The first recognised tool in
127/// `program`-then-`args` order wins, which is why a wrapper prefix
128/// (`sudo`, `env`, `VAR=VALUE`) is skipped naturally and why
129/// `apt-get install snap` resolves to `Dpkg`, not `Snap`.
130///
131/// Returning `None` means "no shared lock" — those actions are still blocked
132/// by a held [`ExclusiveResource::System`] but never block each other.
133pub fn exclusive_resource(spec: &ActionSpec) -> Option<ExclusiveResource> {
134    let ActionMechanism::Command { program, args } = &spec.mechanism else {
135        return None;
136    };
137    std::iter::once(*program)
138        .chain(args.iter().map(String::as_str))
139        // A token can itself be a whole shell script (`sh -c "snap install …
140        // && snap refresh --hold …"`), so look inside it too. Splitting on
141        // whitespace and shell operators finds the tool wherever it sits.
142        // Over-matching here costs a little extra serialisation; under-matching
143        // costs a corrupted package database, so this errs toward matching.
144        .flat_map(|token| {
145            token.split(|c: char| c.is_whitespace() || matches!(c, '&' | '|' | ';' | '(' | ')'))
146        })
147        .filter(|word| !word.is_empty())
148        .find_map(|token| {
149            // Match on the binary name so an absolute path still resolves.
150            let name = token.rsplit('/').next().unwrap_or(token);
151            match name {
152                "apt-get"
153                | "apt"
154                | "apt-mark"
155                | "aptitude"
156                | "dpkg"
157                | "dpkg-reconfigure"
158                | "add-apt-repository"
159                | "do-release-upgrade"
160                | "unattended-upgrade"
161                | "sysknife-apt-pin-edit"
162                | "apt-pin-edit" => Some(ExclusiveResource::Dpkg),
163                "snap" => Some(ExclusiveResource::Snap),
164                "rpm-ostree" | "ostree" => Some(ExclusiveResource::RpmOstree),
165                "flatpak" => Some(ExclusiveResource::Flatpak),
166                _ => None,
167            }
168        })
169}
170
171/// The canonical catalogue of every action the daemon recognises, grouped by
172/// domain. This is the **single source of truth** for per-action static
173/// metadata (`risk_level`, `reboot_required`, `rollback_available`): the
174/// preview/approval gate (`preview.rs`), the RBAC role table (`policy.rs`), and
175/// `docs/action-reference.md` all derive from — or are consistency-tested
176/// against — this list rather than re-declaring risk. The section titles are
177/// the only hand-authored presentation input. See `tests/action_consistency.rs`
178/// for the invariants that pin the derived tables to this catalogue.
179pub fn catalogue() -> Vec<(&'static str, Vec<ActionSpec>)> {
180    vec![
181        ("Deployment (atomic host)", deployment::specs()),
182        ("Package layering (rpm-ostree)", layering::specs()),
183        ("Filesystem", filesystem::specs()),
184        ("Flatpak", flatpak::specs()),
185        ("Toolbox", toolbox::specs()),
186        ("Services", services::specs()),
187        ("Processes", processes::specs()),
188        ("Journald", journald::specs()),
189        ("Storage — LVM", lvm::specs()),
190        ("Kernel parameters — sysctl", sysctl::specs()),
191        ("Mounts & swap", mounts::specs()),
192        ("Log management", logging::specs()),
193        ("PAM password policy", pam::specs()),
194        ("auditd", auditd::specs()),
195        ("certbot / ACME", certbot::specs()),
196        ("Scoped sudoers.d", sudoers::specs()),
197        ("Network", network::specs()),
198        ("resolvectl", resolvectl::specs()),
199        ("Identity / time / locale", identity::specs()),
200        ("Users & groups", users::specs()),
201        ("SSH keys", ssh::specs()),
202        ("Package repositories", package_repos::specs()),
203        ("System info", system_info::specs()),
204        ("Containers", containers::specs()),
205        ("Reboot", reboot::specs()),
206        ("AppArmor", apparmor::specs()),
207        ("cloud-init", cloudinit::specs()),
208        ("fail2ban", fail2ban::specs()),
209        ("apt", apt::specs()),
210        ("apt preferences / pinning", apt_preferences::specs()),
211        ("PPA", ppa::specs()),
212        ("snap", snap::specs()),
213        ("ufw", ufw::specs()),
214        ("netplan", netplan::specs()),
215        ("distrobox", distrobox::specs()),
216        ("GRUB", grub::specs()),
217        ("Ubuntu release upgrade", release_upgrade::specs()),
218        ("Ubuntu Pro", ubuntu_pro::specs()),
219        ("Livepatch", livepatch::specs()),
220        ("Multipass", multipass::specs()),
221    ]
222}
223
224/// Every action spec, flattened from [`catalogue`].
225pub fn all_specs() -> Vec<ActionSpec> {
226    catalogue()
227        .into_iter()
228        .flat_map(|(_, specs)| specs)
229        .collect()
230}
231
232/// Static per-action metadata that lives on the [`ActionSpec`] and must not be
233/// re-declared anywhere else. Consumers (preview gate, RBAC) read it via
234/// [`spec_meta`].
235#[derive(Clone, Copy, Debug, PartialEq, Eq)]
236pub struct SpecMeta {
237    pub risk_level: RiskLevel,
238    pub reboot_required: bool,
239    pub rollback_available: bool,
240}
241
242static SPEC_META: std::sync::LazyLock<std::collections::HashMap<&'static str, SpecMeta>> =
243    std::sync::LazyLock::new(|| {
244        all_specs()
245            .into_iter()
246            .map(|s| {
247                (
248                    s.action_name,
249                    SpecMeta {
250                        risk_level: s.risk_level,
251                        reboot_required: s.reboot_required,
252                        rollback_available: s.rollback_available,
253                    },
254                )
255            })
256            .collect()
257    });
258
259/// Canonical static metadata for `action_name`, or `None` for actions with no
260/// `ActionSpec` (e.g. the dispatcher-internal `ListJobHistory`).
261pub fn spec_meta(action_name: &str) -> Option<SpecMeta> {
262    SPEC_META.get(action_name).copied()
263}
264
265pub(crate) fn command_mechanism(
266    program: &'static str,
267    args: impl IntoIterator<Item = impl Into<String>>,
268) -> ActionMechanism {
269    ActionMechanism::Command {
270        program,
271        args: args.into_iter().map(Into::into).collect(),
272    }
273}
274
275pub(crate) fn file_write_mechanism(
276    path: impl Into<String>,
277    content: impl Into<String>,
278) -> ActionMechanism {
279    ActionMechanism::FileWrite {
280        path: path.into(),
281        content: content.into(),
282    }
283}
284
285pub(crate) fn file_patch_mechanism(
286    path: impl Into<String>,
287    search: impl Into<String>,
288    replace: impl Into<String>,
289) -> ActionMechanism {
290    ActionMechanism::FilePatch {
291        path: path.into(),
292        search: search.into(),
293        replace: replace.into(),
294    }
295}
296
297pub(crate) fn file_scan_mechanism(path: impl Into<String>) -> ActionMechanism {
298    ActionMechanism::FileScan { path: path.into() }
299}
300
301pub(crate) fn file_delete_mechanism(path: impl Into<String>) -> ActionMechanism {
302    ActionMechanism::FileDelete { path: path.into() }
303}