Skip to main content

sysknife_daemon/actions/
services.rs

1use super::{command_mechanism, ActionSpec};
2use sysknife_types::RiskLevel;
3
4pub fn specs() -> Vec<ActionSpec> {
5    vec![
6        list_services(),
7        start_service("NetworkManager.service"),
8        stop_service("NetworkManager.service"),
9        restart_service("NetworkManager.service"),
10        set_service_enabled("sshd.service", true),
11        mask_service("cups.service"),
12        unmask_service("cups.service"),
13        get_service_logs("NetworkManager.service"),
14        get_service_status("nginx.service"),
15        reload_service("nginx.service"),
16        list_timers(),
17        reload_daemon(),
18        create_scheduled_job("example", "/usr/bin/true", "*-*-* 02:00:00"),
19        get_service_resource_limits("nginx.service"),
20        set_service_resource_limits(
21            "nginx.service",
22            &["MemoryMax=500M".to_string(), "CPUQuota=50%".to_string()],
23        ),
24    ]
25}
26
27/// Read a service's cgroup resource limits (`systemctl show --property=…`).
28///
29/// Read-only. `CPUQuota` is reported by systemd as `CPUQuotaPerSecUSec`, so
30/// that is the property name queried here.
31pub fn get_service_resource_limits(unit: &str) -> ActionSpec {
32    ActionSpec {
33        action_name: "GetServiceResourceLimits",
34        mechanism: command_mechanism(
35            "systemctl",
36            [
37                "show",
38                unit,
39                "--property=MemoryMax,MemoryHigh,CPUQuotaPerSecUSec,TasksMax",
40            ],
41        ),
42        risk_level: RiskLevel::Low,
43        reboot_required: false,
44        rollback_available: false,
45    }
46}
47
48/// Set a service's cgroup resource limits (`sudo systemctl set-property …`).
49///
50/// Risk: Medium. `set-property` both applies the limit live and writes a
51/// persistent drop-in under `/etc/systemd/system.control/<unit>.d/`; the change
52/// is undone with `systemctl revert <unit>`. Using systemd's own verb (rather
53/// than a hand-written drop-in helper) means systemd validates and manages the
54/// unit files. `assignments` are pre-validated `PROPERTY=VALUE` pairs
55/// (`MemoryMax=`, `CPUQuota=`, `TasksMax=`); the executor guarantees at least
56/// one is present.
57pub fn set_service_resource_limits(unit: &str, assignments: &[String]) -> ActionSpec {
58    let mut args = vec![
59        "systemctl".to_string(),
60        "set-property".to_string(),
61        unit.to_string(),
62    ];
63    args.extend(assignments.iter().cloned());
64    ActionSpec {
65        action_name: "SetServiceResourceLimits",
66        mechanism: command_mechanism("sudo", args),
67        risk_level: RiskLevel::Medium,
68        reboot_required: false,
69        rollback_available: false,
70    }
71}
72
73/// Installed path of the privileged scheduled-job helper script.
74/// See `packaging/sysknife-scheduled-job-edit` and the matching NOPASSWD grant
75/// in `packaging/sysknife-sudoers`.
76const SCHEDULED_JOB_HELPER: &str = "/usr/lib/sysknife/scheduled-job-edit";
77const SCHEDULED_JOB_UNIT_DIR: &str = "/etc/systemd/system";
78const SCHEDULED_JOB_UNIT_PREFIX: &str = "sysknife-";
79
80/// Return the exact unit paths owned by a validated scheduled-job name.
81///
82/// The daemon owns this preview/display string; the privileged helper performs
83/// the actual exclusive filesystem create using the same fixed namespace.
84pub fn scheduled_job_unit_paths(name: &str) -> Option<(String, String)> {
85    if name.is_empty()
86        || name.len() > 64
87        || !name
88            .chars()
89            .next()
90            .is_some_and(|c| c.is_ascii_alphanumeric())
91        || !name
92            .chars()
93            .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
94    {
95        return None;
96    }
97
98    let stem = format!("{SCHEDULED_JOB_UNIT_PREFIX}{name}");
99    Some((
100        format!("{SCHEDULED_JOB_UNIT_DIR}/{stem}.service"),
101        format!("{SCHEDULED_JOB_UNIT_DIR}/{stem}.timer"),
102    ))
103}
104
105/// Installed path of the bounded multi-step helper.
106///
107/// The eight unit verbs run through `action-steps unit <verb> <unit>` rather
108/// than through a `sudo systemctl <verb> *` grant. sudoers matches arguments as
109/// one concatenated string, so the unit name sat in a wildcard that also
110/// accepted `debug-shell.service`, which the daemon's own `ROOT_SHELL_UNITS`
111/// denylist refuses and a grant cannot. The helper applies that denylist again,
112/// for the verbs that bring a unit up, on whatever path reaches it.
113const ACTION_STEPS: &str = "/usr/lib/sysknife/action-steps";
114
115/// Create a recurring scheduled job as a systemd `.service` + `.timer` pair.
116///
117/// Risk: High. Persistent root-scheduled execution. Delegates to the root-owned
118/// helper, which re-validates the job name, rejects control characters in the
119/// command (blocking unit-file injection), validates the `OnCalendar`
120/// expression with `systemd-analyze calendar`, creates only namespaced
121/// `sysknife-<name>` units without overwriting existing paths, `daemon-reload`s,
122/// and enables+starts the timer. The command is written to
123/// `ExecStart`, which systemd argv-splits with no shell.
124pub fn create_scheduled_job(name: &str, command: &str, schedule: &str) -> ActionSpec {
125    ActionSpec {
126        action_name: "CreateScheduledJob",
127        mechanism: command_mechanism(
128            "sudo",
129            [
130                SCHEDULED_JOB_HELPER,
131                "--name",
132                name,
133                "--command",
134                command,
135                "--schedule",
136                schedule,
137            ],
138        ),
139        risk_level: RiskLevel::High,
140        reboot_required: false,
141        rollback_available: false,
142    }
143}
144
145pub fn list_services() -> ActionSpec {
146    ActionSpec {
147        action_name: "ListServices",
148        mechanism: command_mechanism(
149            "systemctl",
150            [
151                "list-units",
152                "--type=service",
153                "--all",
154                "--no-legend",
155                "--no-pager",
156            ],
157        ),
158        risk_level: RiskLevel::Low,
159        reboot_required: false,
160        rollback_available: false,
161    }
162}
163
164pub fn start_service(unit: &str) -> ActionSpec {
165    ActionSpec {
166        action_name: "StartService",
167        mechanism: command_mechanism("sudo", [ACTION_STEPS, "unit", "start", unit]),
168        risk_level: RiskLevel::Medium,
169        reboot_required: false,
170        rollback_available: false,
171    }
172}
173
174pub fn stop_service(unit: &str) -> ActionSpec {
175    ActionSpec {
176        action_name: "StopService",
177        mechanism: command_mechanism("sudo", [ACTION_STEPS, "unit", "stop", unit]),
178        risk_level: RiskLevel::Medium,
179        reboot_required: false,
180        rollback_available: false,
181    }
182}
183
184pub fn restart_service(unit: &str) -> ActionSpec {
185    ActionSpec {
186        action_name: "RestartService",
187        mechanism: command_mechanism("sudo", [ACTION_STEPS, "unit", "restart", unit]),
188        risk_level: RiskLevel::Medium,
189        reboot_required: false,
190        rollback_available: false,
191    }
192}
193
194pub fn set_service_enabled(unit: &str, enabled: bool) -> ActionSpec {
195    let verb = if enabled { "enable" } else { "disable" };
196
197    ActionSpec {
198        action_name: "SetServiceEnabled",
199        mechanism: command_mechanism("sudo", [ACTION_STEPS, "unit", verb, unit]),
200        risk_level: RiskLevel::Medium,
201        reboot_required: false,
202        rollback_available: false,
203    }
204}
205
206pub fn mask_service(unit: &str) -> ActionSpec {
207    ActionSpec {
208        action_name: "MaskService",
209        mechanism: command_mechanism("sudo", [ACTION_STEPS, "unit", "mask", unit]),
210        risk_level: RiskLevel::High,
211        reboot_required: false,
212        rollback_available: false,
213    }
214}
215
216pub fn unmask_service(unit: &str) -> ActionSpec {
217    ActionSpec {
218        action_name: "UnmaskService",
219        mechanism: command_mechanism("sudo", [ACTION_STEPS, "unit", "unmask", unit]),
220        risk_level: RiskLevel::Medium,
221        reboot_required: false,
222        rollback_available: false,
223    }
224}
225
226pub fn get_service_status(unit: &str) -> ActionSpec {
227    // Detailed unit status: active state, sub-state, loaded/enabled state,
228    // recent log lines, and PID. More informative than `list-units` for
229    // diagnosing a specific unit.
230    ActionSpec {
231        action_name: "GetServiceStatus",
232        mechanism: command_mechanism("systemctl", ["status", unit, "--no-pager"]),
233        risk_level: RiskLevel::Low,
234        reboot_required: false,
235        rollback_available: false,
236    }
237}
238
239pub fn reload_service(unit: &str) -> ActionSpec {
240    // Trigger the unit's configured reload procedure (ExecReload=) without
241    // stopping it. Only valid for units that define ExecReload= — attempting
242    // this on a unit without it will fail with "Job type reload is not
243    // applicable for unit X". Prefer over RestartService when live reload
244    // is sufficient and the unit supports it.
245    ActionSpec {
246        action_name: "ReloadService",
247        mechanism: command_mechanism("sudo", [ACTION_STEPS, "unit", "reload", unit]),
248        risk_level: RiskLevel::Medium,
249        reboot_required: false,
250        rollback_available: false,
251    }
252}
253
254pub fn list_timers() -> ActionSpec {
255    // Show all systemd timer units with their last/next trigger times.
256    // Includes both active and inactive timers — useful for auditing
257    // scheduled tasks.
258    ActionSpec {
259        action_name: "ListTimers",
260        mechanism: command_mechanism(
261            "systemctl",
262            ["list-timers", "--all", "--no-legend", "--no-pager"],
263        ),
264        risk_level: RiskLevel::Low,
265        reboot_required: false,
266        rollback_available: false,
267    }
268}
269
270pub fn reload_daemon() -> ActionSpec {
271    // Reload systemd manager configuration after any unit file change on disk —
272    // including new files, edits to existing files, and drop-in overrides in
273    // .d/ directories. Without daemon-reload, systemctl start/enable continue
274    // using the previously-loaded definition even if the file has changed.
275    ActionSpec {
276        action_name: "ReloadDaemon",
277        mechanism: command_mechanism("sudo", ["systemctl", "daemon-reload"]),
278        risk_level: RiskLevel::Medium,
279        reboot_required: false,
280        rollback_available: false,
281    }
282}
283
284pub fn get_service_logs(unit: &str) -> ActionSpec {
285    ActionSpec {
286        action_name: "GetServiceLogs",
287        mechanism: command_mechanism("journalctl", ["-u", unit, "-n", "200", "--no-pager"]),
288        risk_level: RiskLevel::Low,
289        reboot_required: false,
290        rollback_available: false,
291    }
292}
293
294#[cfg(test)]
295mod tests {
296    use super::*;
297    use crate::actions::ActionMechanism;
298
299    fn args_of(spec: &ActionSpec) -> (&'static str, Vec<String>) {
300        match &spec.mechanism {
301            ActionMechanism::Command { program, args } => (program, args.clone()),
302            other => panic!("expected Command, got {other:?}"),
303        }
304    }
305
306    #[test]
307    fn get_resource_limits_is_read_only_show() {
308        let spec = get_service_resource_limits("nginx.service");
309        let (program, args) = args_of(&spec);
310        assert_eq!(program, "systemctl");
311        assert_eq!(args[0], "show");
312        assert_eq!(args[1], "nginx.service");
313        assert!(args[2].contains("MemoryMax") && args[2].contains("CPUQuotaPerSecUSec"));
314        assert_eq!(spec.risk_level, RiskLevel::Low);
315    }
316
317    #[test]
318    fn set_resource_limits_builds_set_property() {
319        let spec = set_service_resource_limits(
320            "nginx.service",
321            &["MemoryMax=500M".to_string(), "CPUQuota=50%".to_string()],
322        );
323        let (program, args) = args_of(&spec);
324        assert_eq!(program, "sudo");
325        assert_eq!(
326            args,
327            vec![
328                "systemctl",
329                "set-property",
330                "nginx.service",
331                "MemoryMax=500M",
332                "CPUQuota=50%"
333            ]
334        );
335        assert_eq!(spec.risk_level, RiskLevel::Medium);
336    }
337}