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("sysknife-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";
77
78/// Create a recurring scheduled job as a systemd `.service` + `.timer` pair.
79///
80/// Risk: High. Persistent root-scheduled execution. Delegates to the root-owned
81/// helper, which re-validates the job name, rejects control characters in the
82/// command (blocking unit-file injection), validates the `OnCalendar`
83/// expression with `systemd-analyze calendar`, writes the units,
84/// `daemon-reload`s, and enables+starts the timer. The command is written to
85/// `ExecStart`, which systemd argv-splits with no shell.
86pub fn create_scheduled_job(name: &str, command: &str, schedule: &str) -> ActionSpec {
87    ActionSpec {
88        action_name: "CreateScheduledJob",
89        mechanism: command_mechanism(
90            "sudo",
91            [
92                SCHEDULED_JOB_HELPER,
93                "--name",
94                name,
95                "--command",
96                command,
97                "--schedule",
98                schedule,
99            ],
100        ),
101        risk_level: RiskLevel::High,
102        reboot_required: false,
103        rollback_available: false,
104    }
105}
106
107pub fn list_services() -> ActionSpec {
108    ActionSpec {
109        action_name: "ListServices",
110        mechanism: command_mechanism(
111            "systemctl",
112            [
113                "list-units",
114                "--type=service",
115                "--all",
116                "--no-legend",
117                "--no-pager",
118            ],
119        ),
120        risk_level: RiskLevel::Low,
121        reboot_required: false,
122        rollback_available: false,
123    }
124}
125
126pub fn start_service(unit: &str) -> ActionSpec {
127    ActionSpec {
128        action_name: "StartService",
129        mechanism: command_mechanism("sudo", ["systemctl", "start", unit]),
130        risk_level: RiskLevel::Medium,
131        reboot_required: false,
132        rollback_available: false,
133    }
134}
135
136pub fn stop_service(unit: &str) -> ActionSpec {
137    ActionSpec {
138        action_name: "StopService",
139        mechanism: command_mechanism("sudo", ["systemctl", "stop", unit]),
140        risk_level: RiskLevel::Medium,
141        reboot_required: false,
142        rollback_available: false,
143    }
144}
145
146pub fn restart_service(unit: &str) -> ActionSpec {
147    ActionSpec {
148        action_name: "RestartService",
149        mechanism: command_mechanism("sudo", ["systemctl", "restart", unit]),
150        risk_level: RiskLevel::Medium,
151        reboot_required: false,
152        rollback_available: false,
153    }
154}
155
156pub fn set_service_enabled(unit: &str, enabled: bool) -> ActionSpec {
157    let verb = if enabled { "enable" } else { "disable" };
158
159    ActionSpec {
160        action_name: "SetServiceEnabled",
161        mechanism: command_mechanism("sudo", ["systemctl", verb, unit]),
162        risk_level: RiskLevel::Medium,
163        reboot_required: false,
164        rollback_available: false,
165    }
166}
167
168pub fn mask_service(unit: &str) -> ActionSpec {
169    ActionSpec {
170        action_name: "MaskService",
171        mechanism: command_mechanism("sudo", ["systemctl", "mask", unit]),
172        risk_level: RiskLevel::High,
173        reboot_required: false,
174        rollback_available: false,
175    }
176}
177
178pub fn unmask_service(unit: &str) -> ActionSpec {
179    ActionSpec {
180        action_name: "UnmaskService",
181        mechanism: command_mechanism("sudo", ["systemctl", "unmask", unit]),
182        risk_level: RiskLevel::Medium,
183        reboot_required: false,
184        rollback_available: false,
185    }
186}
187
188pub fn get_service_status(unit: &str) -> ActionSpec {
189    // Detailed unit status: active state, sub-state, loaded/enabled state,
190    // recent log lines, and PID. More informative than `list-units` for
191    // diagnosing a specific unit.
192    ActionSpec {
193        action_name: "GetServiceStatus",
194        mechanism: command_mechanism("systemctl", ["status", unit, "--no-pager"]),
195        risk_level: RiskLevel::Low,
196        reboot_required: false,
197        rollback_available: false,
198    }
199}
200
201pub fn reload_service(unit: &str) -> ActionSpec {
202    // Trigger the unit's configured reload procedure (ExecReload=) without
203    // stopping it. Only valid for units that define ExecReload= — attempting
204    // this on a unit without it will fail with "Job type reload is not
205    // applicable for unit X". Prefer over RestartService when live reload
206    // is sufficient and the unit supports it.
207    ActionSpec {
208        action_name: "ReloadService",
209        mechanism: command_mechanism("sudo", ["systemctl", "reload", unit]),
210        risk_level: RiskLevel::Medium,
211        reboot_required: false,
212        rollback_available: false,
213    }
214}
215
216pub fn list_timers() -> ActionSpec {
217    // Show all systemd timer units with their last/next trigger times.
218    // Includes both active and inactive timers — useful for auditing
219    // scheduled tasks.
220    ActionSpec {
221        action_name: "ListTimers",
222        mechanism: command_mechanism(
223            "systemctl",
224            ["list-timers", "--all", "--no-legend", "--no-pager"],
225        ),
226        risk_level: RiskLevel::Low,
227        reboot_required: false,
228        rollback_available: false,
229    }
230}
231
232pub fn reload_daemon() -> ActionSpec {
233    // Reload systemd manager configuration after any unit file change on disk —
234    // including new files, edits to existing files, and drop-in overrides in
235    // .d/ directories. Without daemon-reload, systemctl start/enable continue
236    // using the previously-loaded definition even if the file has changed.
237    ActionSpec {
238        action_name: "ReloadDaemon",
239        mechanism: command_mechanism("sudo", ["systemctl", "daemon-reload"]),
240        risk_level: RiskLevel::Medium,
241        reboot_required: false,
242        rollback_available: false,
243    }
244}
245
246pub fn get_service_logs(unit: &str) -> ActionSpec {
247    ActionSpec {
248        action_name: "GetServiceLogs",
249        mechanism: command_mechanism("journalctl", ["-u", unit, "-n", "200", "--no-pager"]),
250        risk_level: RiskLevel::Low,
251        reboot_required: false,
252        rollback_available: false,
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259    use crate::actions::ActionMechanism;
260
261    fn args_of(spec: &ActionSpec) -> (&'static str, Vec<String>) {
262        match &spec.mechanism {
263            ActionMechanism::Command { program, args } => (program, args.clone()),
264            other => panic!("expected Command, got {other:?}"),
265        }
266    }
267
268    #[test]
269    fn get_resource_limits_is_read_only_show() {
270        let spec = get_service_resource_limits("nginx.service");
271        let (program, args) = args_of(&spec);
272        assert_eq!(program, "systemctl");
273        assert_eq!(args[0], "show");
274        assert_eq!(args[1], "nginx.service");
275        assert!(args[2].contains("MemoryMax") && args[2].contains("CPUQuotaPerSecUSec"));
276        assert_eq!(spec.risk_level, RiskLevel::Low);
277    }
278
279    #[test]
280    fn set_resource_limits_builds_set_property() {
281        let spec = set_service_resource_limits(
282            "nginx.service",
283            &["MemoryMax=500M".to_string(), "CPUQuota=50%".to_string()],
284        );
285        let (program, args) = args_of(&spec);
286        assert_eq!(program, "sudo");
287        assert_eq!(
288            args,
289            vec![
290                "systemctl",
291                "set-property",
292                "nginx.service",
293                "MemoryMax=500M",
294                "CPUQuota=50%"
295            ]
296        );
297        assert_eq!(spec.risk_level, RiskLevel::Medium);
298    }
299}