1use crate::actions::{
2 apparmor, apt, apt_preferences, auditd, certbot, cloudinit, containers, deployment, distrobox,
3 fail2ban, filesystem, flatpak, grub, identity, journald, layering, livepatch, logging, lvm,
4 mounts, multipass, netplan, network, package_repos, pam, ppa, processes, reboot,
5 release_upgrade, resolvectl, services, snap, ssh, sudoers, sysctl, system_info, toolbox,
6 ubuntu_pro, ufw, users,
7 validate::{
8 shell_equivalent_sudo_command, validated_activatable_unit, validated_apparmor_profile,
9 validated_apt_package, validated_apt_pin_expr, validated_apt_pin_name,
10 validated_apt_show_package, validated_audit_path, validated_audit_perms,
11 validated_cpu_quota, validated_domain, validated_email, validated_fstype, validated_group,
12 validated_group_not_critical, validated_home_dir, validated_hostname,
13 validated_install_package, validated_journal_grep, validated_journal_priority,
14 validated_journal_time, validated_locale, validated_log_path, validated_log_rotation,
15 validated_lvm_name, validated_lvm_size, validated_memory_limit, validated_mount_device,
16 validated_mount_options, validated_mount_point, validated_port_or_service,
17 validated_ppa_name, validated_pro_service, validated_rebase_ref,
18 validated_resource_limit_unit, validated_safe_arg, validated_stoppable_unit,
19 validated_sudo_commands, validated_sudoers_name, validated_swap_path, validated_sysctl_key,
20 validated_sysctl_value, validated_syslog_host, validated_tasks_max, validated_timezone,
21 validated_unit_name, validated_unmodified_package, validated_username,
22 validated_username_not_critical,
23 },
24 ActionMechanism, ActionSpec,
25};
26use async_trait::async_trait;
27use serde_json::Value;
28use std::io;
29use std::net::IpAddr;
30use std::process::Stdio;
31use std::str::FromStr;
32use std::time::Duration;
33use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
34use tokio::sync::mpsc::UnboundedSender;
35
36const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
47const MAX_SCHEDULE_EXPR_LEN: usize = 128;
49const MAX_JOURNAL_LINES: u64 = 10_000;
52const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
54const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
56const MAX_PASSWORD_MINLEN: u64 = 128;
58const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
60const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
62
63#[derive(Debug, thiserror::Error)]
64pub enum ExecutorError {
65 #[error("unknown action: {0}")]
66 UnknownAction(String),
67
68 #[error("missing required param: {0}")]
69 MissingParam(&'static str),
70
71 #[error("invalid param type for: {0}")]
72 InvalidParam(&'static str),
73
74 #[error("invalid IP address for param '{param}': '{value}'")]
80 InvalidIpAddress { param: &'static str, value: String },
81
82 #[error("io error: {0}")]
83 Io(#[from] io::Error),
84
85 #[error(
94 "{program} exceeded the {timeout_secs}s action timeout and could not be \
95 confirmed stopped: process group {pgid} still has members. Automatic \
96 rollback was skipped because the original action may still be running; \
97 inspect the host before retrying"
98 )]
99 ActionNotStopped {
100 program: String,
101 pgid: i32,
102 timeout_secs: u64,
103 },
104}
105
106#[derive(Debug, Clone, PartialEq, Eq)]
115pub struct ExecutionOutput {
116 pub stdout: String,
117 pub stderr: String,
118 pub exit_code: i32,
119}
120
121impl ExecutionOutput {
122 pub fn is_success(&self) -> bool {
124 self.exit_code == 0
125 }
126
127 pub fn is_nonzero(&self) -> bool {
129 self.exit_code != 0
130 }
131}
132
133#[async_trait]
140pub trait ActionExecutor: Send + Sync {
141 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
143
144 async fn execute_with_progress(
150 &self,
151 spec: &ActionSpec,
152 progress: UnboundedSender<String>,
153 ) -> Result<ExecutionOutput, ExecutorError> {
154 let output = self.execute(spec).await?;
155 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
156 let _ = progress.send(line.to_string());
157 }
158 Ok(output)
159 }
160}
161
162pub struct RealActionExecutor;
164
165#[async_trait]
166impl ActionExecutor for RealActionExecutor {
167 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
168 execute_spec(spec).await
169 }
170
171 async fn execute_with_progress(
172 &self,
173 spec: &ActionSpec,
174 progress: UnboundedSender<String>,
175 ) -> Result<ExecutionOutput, ExecutorError> {
176 match &spec.mechanism {
177 ActionMechanism::Command { program, args } => {
178 execute_command_with_progress(program, args, progress).await
179 }
180 _ => execute_spec(spec).await,
181 }
182 }
183}
184
185const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
199
200fn action_timeout() -> Duration {
201 match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
202 Ok(raw) => match raw.trim().parse::<u64>() {
203 Ok(secs) if secs > 0 => Duration::from_secs(secs),
204 _ => {
205 eprintln!(
206 "[sysknife-daemon] WARNING: ignoring invalid \
207 SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
208 );
209 DEFAULT_ACTION_TIMEOUT
210 }
211 },
212 Err(_) => DEFAULT_ACTION_TIMEOUT,
213 }
214}
215
216#[derive(Debug, Clone, Copy, PartialEq, Eq)]
218enum GroupState {
219 Gone,
221 AliveReachable,
223 AliveUnreachable,
228 Unknown(i32),
231}
232
233impl GroupState {
234 fn is_gone(self) -> bool {
235 matches!(self, GroupState::Gone)
236 }
237}
238
239fn probe_group(pgid: i32) -> GroupState {
241 let rc = unsafe { libc::killpg(pgid, 0) };
246 if rc == 0 {
247 return GroupState::AliveReachable;
248 }
249 match io::Error::last_os_error().raw_os_error() {
250 Some(libc::ESRCH) => GroupState::Gone,
251 Some(libc::EPERM) => GroupState::AliveUnreachable,
252 other => GroupState::Unknown(other.unwrap_or(0)),
253 }
254}
255
256fn signal_group(pgid: i32, signal: i32) -> Result<(), io::Error> {
258 let rc = unsafe { libc::killpg(pgid, signal) };
260 if rc == 0 {
261 return Ok(());
262 }
263 let err = io::Error::last_os_error();
264 if err.raw_os_error() == Some(libc::ESRCH) {
265 return Ok(());
266 }
267 Err(err)
268}
269
270const GROUP_TERM_GRACE: Duration = Duration::from_secs(5);
275
276fn is_rpm_ostree_action(program: &str, args: &[String]) -> bool {
282 let is_ostree = |s: &str| s == "rpm-ostree" || s.ends_with("/rpm-ostree");
283 is_ostree(program) || args.iter().any(|a| is_ostree(a))
284}
285
286fn root_group_kill_argv(signal: &str, pgid: i32) -> Vec<String> {
293 vec![
294 "-n".to_string(),
295 "/usr/lib/sysknife/action-steps".to_string(),
296 "signal".to_string(),
297 signal.to_string(),
298 format!("-{pgid}"),
299 ]
300}
301
302#[cfg(test)]
305static SUDO_INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
306
307async fn run_sudo(args: Vec<String>) {
314 #[cfg(test)]
315 SUDO_INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
316 let fut = tokio::process::Command::new("sudo")
317 .args(&args)
318 .stdin(Stdio::null())
319 .stdout(Stdio::null())
320 .stderr(Stdio::null())
321 .status();
322 let joined = args.join(" ");
323 match tokio::time::timeout(REAP_WAIT_CAP, fut).await {
324 Ok(Ok(status)) if status.success() => {}
325 Ok(Ok(status)) => {
326 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` exited {status}")
327 }
328 Ok(Err(e)) => {
329 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` could not run: {e}")
330 }
331 Err(_) => eprintln!(
332 "[sysknife-daemon] privileged reap `sudo {joined}` timed out after {}s",
333 REAP_WAIT_CAP.as_secs()
334 ),
335 }
336}
337
338fn reap_command(pgid: i32, program: &str, args: &[String]) -> Vec<String> {
344 if is_rpm_ostree_action(program, args) {
345 vec![
346 "-n".to_string(),
347 "/usr/bin/rpm-ostree".to_string(),
348 "cancel".to_string(),
349 ]
350 } else {
351 root_group_kill_argv("TERM", pgid)
352 }
353}
354
355async fn privileged_reap(pgid: i32, program: &str, args: &[String]) {
361 run_sudo(reap_command(pgid, program, args)).await;
362 if is_rpm_ostree_action(program, args) {
369 return;
370 }
371 tokio::time::sleep(Duration::from_millis(300)).await;
372 if !probe_group(pgid).is_gone() {
373 run_sudo(root_group_kill_argv("KILL", pgid)).await;
374 }
375}
376
377const REAP_WAIT_CAP: Duration = Duration::from_secs(2);
385
386async fn kill_and_reap(
404 child: &mut tokio::process::Child,
405 program: &str,
406 args: &[String],
407 cause: StopCause,
408) -> ExecutorError {
409 let secs = action_timeout().as_secs();
410 let why = cause.describe(secs);
411 let Some(pgid) = child.id().map(|pid| pid as i32) else {
412 eprintln!("[sysknife-daemon] {program} {why}; already exited");
415 return cause.into_error(program, secs);
416 };
417
418 let _ = child.start_kill();
422 if let Err(err) = signal_group(pgid, libc::SIGTERM) {
423 eprintln!("[sysknife-daemon] SIGTERM to process group {pgid} failed: {err}");
424 }
425
426 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
430
431 let grace = tokio::time::Instant::now() + GROUP_TERM_GRACE;
432 while tokio::time::Instant::now() < grace && !probe_group(pgid).is_gone() {
433 tokio::time::sleep(Duration::from_millis(50)).await;
434 }
435 if !probe_group(pgid).is_gone() {
436 if let Err(err) = signal_group(pgid, libc::SIGKILL) {
437 eprintln!("[sysknife-daemon] SIGKILL to process group {pgid} failed: {err}");
438 }
439 tokio::time::sleep(Duration::from_millis(200)).await;
440 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
441 }
442
443 if probe_group(pgid) == GroupState::AliveUnreachable {
459 eprintln!(
460 "[sysknife-daemon] {program} process group {pgid} survived as root; \
461 escalating to a privileged reap"
462 );
463 privileged_reap(pgid, program, args).await;
464 tokio::time::sleep(Duration::from_millis(200)).await;
465 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
466 }
467
468 match probe_group(pgid) {
469 GroupState::Gone => {
470 eprintln!("[sysknife-daemon] {program} {why}; process group {pgid} stopped");
471 cause.into_error(program, secs)
472 }
473 state => {
474 eprintln!(
475 "[sysknife-daemon] {program} {why} and process group {pgid} \
476 could not be confirmed stopped ({state:?})"
477 );
478 ExecutorError::ActionNotStopped {
479 program: program.to_string(),
480 pgid,
481 timeout_secs: secs,
482 }
483 }
484 }
485}
486
487enum StopCause {
495 Timeout,
497 Unreadable(io::Error),
501}
502
503impl StopCause {
504 fn describe(&self, secs: u64) -> String {
506 match self {
507 StopCause::Timeout => format!("exceeded the {secs}s action timeout"),
508 StopCause::Unreadable(err) => format!("could not be read ({err})"),
509 }
510 }
511
512 fn into_error(self, program: &str, secs: u64) -> ExecutorError {
517 match self {
518 StopCause::Timeout => ExecutorError::Io(io::Error::new(
519 io::ErrorKind::TimedOut,
520 format!("{program} exceeded the {secs}s action timeout and was stopped"),
521 )),
522 StopCause::Unreadable(err) => ExecutorError::Io(err),
523 }
524 }
525}
526
527async fn execute_command_with_progress(
528 program: &'static str,
529 args: &[String],
530 progress: UnboundedSender<String>,
531) -> Result<ExecutionOutput, ExecutorError> {
532 let mut child = tokio::process::Command::new(program)
533 .args(args)
534 .process_group(0)
539 .stdin(Stdio::null())
544 .stdout(Stdio::piped())
545 .stderr(Stdio::piped())
546 .spawn()
547 .map_err(ExecutorError::Io)?;
548
549 let stdout = child.stdout.take().expect("stdout was piped");
550 let stderr = child.stderr.take().expect("stderr was piped");
551 let stderr_task = tokio::spawn(async move {
552 let mut buf = Vec::new();
553 BufReader::new(stderr)
554 .read_to_end(&mut buf)
555 .await
556 .map(|_| buf)
557 });
558
559 let deadline = tokio::time::Instant::now() + action_timeout();
560
561 let mut lines = BufReader::new(stdout).lines();
562 let mut stdout_buf = String::new();
563 loop {
564 let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
567 Ok(Ok(line)) => line,
568 Ok(Err(err)) => {
573 return Err(
574 kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await,
575 )
576 }
577 Err(_) => {
578 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
579 }
580 };
581 let Some(line) = next else { break };
582 if !line.is_empty() {
583 let _ = progress.send(line.clone());
584 }
585 stdout_buf.push_str(&line);
586 stdout_buf.push('\n');
587 }
588
589 let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
590 Ok(Ok(status)) => status,
591 Ok(Err(err)) => {
594 return Err(kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await)
595 }
596 Err(_) => return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await),
597 };
598 let stderr_bytes = stderr_task
599 .await
600 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
601 .map_err(ExecutorError::Io)?;
602
603 Ok(ExecutionOutput {
604 stdout: stdout_buf,
605 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
606 exit_code: exit_status.code().unwrap_or(-1),
607 })
608}
609
610fn allowed_action_params(action_name: &str) -> Option<&'static [&'static str]> {
614 Some(match action_name {
615 "GetSystemState"
616 | "CollectDiagnostics"
617 | "GetDeploymentHistory"
618 | "ListDeployments"
619 | "UpdateSystem"
620 | "CleanupDeployments"
621 | "RebootSystem"
622 | "RollbackDeployment"
623 | "GetKernelArguments"
624 | "GetLayeredPackages"
625 | "ResetLayeredPackageOverride"
626 | "GetPendingUpdates"
627 | "ListPackageRepositories"
628 | "ListServices"
629 | "ListTimers"
630 | "ReloadDaemon"
631 | "GetDateTime"
632 | "GetDiskUsage"
633 | "ListProcesses"
634 | "GetLvmReport"
635 | "GetMounts"
636 | "GetSudoGrants"
637 | "RemoveRemoteSyslog"
638 | "GetMemoryInfo"
639 | "GetHostState"
640 | "GetFirewallState"
641 | "GetNetworkStatus"
642 | "GetListeningPorts"
643 | "GetNftablesRuleset"
644 | "GetFirewallBackendState"
645 | "ListUsers"
646 | "ListGroups"
647 | "AptUpdate"
648 | "AptUpgrade"
649 | "AptAutoremove"
650 | "AptListInstalled"
651 | "AptListUpgradable"
652 | "AptHistoryList"
653 | "SnapList"
654 | "GrubGetKargs"
655 | "CheckPendingReboot"
656 | "UfwEnable"
657 | "UfwDisable"
658 | "UfwReset"
659 | "DistroboxList"
660 | "NetplanGetConfig"
661 | "NetplanApply"
662 | "NetplanGenerate"
663 | "ProStatus"
664 | "ProDetach"
665 | "LivepatchStatus"
666 | "MultipassList"
667 | "UbuntuReleaseUpgrade"
668 | "ResolvectlStatus"
669 | "AppArmorStatus"
670 | "CloudInitStatus"
671 | "GetAuditRules"
672 | "GetCertificates"
673 | "RenewCertificates" => &[],
674 "PinDeployment" | "UnpinDeployment" => &["index"],
675 "RebaseSystem" => &["target_ref"],
676 "SetKernelArguments" => &["add", "remove"],
677 "ListFlatpakRemotes"
678 | "ListInstalledFlatpaks"
679 | "ListContainers"
680 | "ListToolboxes"
681 | "DeleteUser"
682 | "LockUserAccount"
683 | "UnlockUserAccount"
684 | "GetAuthorizedKeys"
685 | "UbuntuListFlatpaks" => &["user", "username"],
686 "InstallFlatpak" | "UbuntuInstallFlatpak" => &["app_id", "remote", "user", "username"],
687 "RemoveFlatpak"
688 | "GetFlatpakAppInfo"
689 | "UpdateFlatpak"
690 | "UbuntuRemoveFlatpak"
691 | "UbuntuUpdateFlatpak" => &["app_id", "user", "username"],
692 "SearchFlatpakApps" | "AptSearch" => &["term"],
693 "AddFlatpakRemote" => &["remote", "url", "user", "username"],
694 "RemoveFlatpakRemote" => &["remote", "user", "username"],
695 "CreateContainer" => &["image", "name", "user", "username"],
696 "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
697 | "RemoveToolbox" => &["name", "user", "username"],
698 "InstallPackages" | "RemovePackages" => &["packages"],
699 "AddLayeredPackage"
700 | "RemoveLayeredPackage"
701 | "RemoveBasePackage"
702 | "GetAptPins"
703 | "AptInstall"
704 | "AptRemove"
705 | "AptPurge"
706 | "AptHold"
707 | "AptUnhold"
708 | "AptShow" => &["package"],
709 "ReplaceLayeredPackage" => &["new", "old"],
710 "AddPackageRepository" => &["repo_id", "repo_url"],
711 "RemovePackageRepository" | "EnablePackageRepository" | "DisablePackageRepository" => {
712 &["repo_id"]
713 }
714 "StartService"
715 | "StopService"
716 | "RestartService"
717 | "MaskService"
718 | "UnmaskService"
719 | "GetServiceLogs"
720 | "GetServiceStatus"
721 | "ReloadService"
722 | "GetServiceResourceLimits" => &["unit"],
723 "SetServiceEnabled" => &["enabled", "unit"],
724 "CreateScheduledJob" => &["command", "name", "schedule"],
725 "SetServiceResourceLimits" => &[
726 "cpu_quota",
727 "memory_high",
728 "memory_max",
729 "tasks_max",
730 "unit",
731 ],
732 "CreateToolbox" => &["image", "name", "release", "user", "username"],
733 "SetHostname" => &["hostname"],
734 "SetTimezone" => &["timezone"],
735 "SetLocale" => &["locale"],
736 "SetNtp" | "ConfigureUnattendedUpgrades" => &["enabled"],
737 "SignalProcess" => &["pid", "signal"],
738 "GetJournalLog" => &[
739 "boot", "grep", "kernel", "lines", "priority", "since", "unit", "until",
740 ],
741 "VacuumJournal" => &["retain_days", "size_mb"],
742 "ExtendLogicalVolume" => &["lv", "size", "vg"],
743 "CreateLogicalVolume" => &["name", "size", "vg"],
744 "CreateLvSnapshot" => &["origin", "size", "snapshot", "vg"],
745 "GetSysctl" | "RemoveAuditRule" => &["key"],
746 "SetSysctl" | "NetplanSet" => &["key", "value"],
747 "AddMount" => &["device", "fstype", "mountpoint", "options"],
748 "RemoveMount" => &["mountpoint"],
749 "AddSwap" => &["file", "size_mb"],
750 "RemoveSwap" => &["file"],
751 "GrantSudoAccess" => &["commands", "name", "nopasswd", "runas", "user"],
752 "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" | "AddPpa" | "RemovePpa"
753 | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo" | "SnapRevert"
754 | "SnapClassicInstall" | "DistroboxRemove" => &["name"],
755 "SetAptPin" => &["name", "package", "pin", "priority"],
756 "GetLogrotateStatus" => &["config"],
757 "ConfigureLogRotation" => &["compress", "frequency", "name", "path", "rotate"],
758 "ConfigureRemoteSyslog" => &["host", "port", "protocol"],
759 "GetPasswordAging" => &["user"],
760 "SetPasswordAging" => &["max_days", "min_days", "user", "warn_days"],
761 "SetPasswordPolicy" => &["dcredit", "lcredit", "minlen", "ocredit", "ucredit"],
762 "SetAccountLockout" => &["deny", "fail_interval", "unlock_time"],
763 "ConfigureWifi" => &["password", "ssid"],
764 "SetDnsServers" | "ResolvectlSetDns" => &["interface", "servers"],
765 "ConfigureFirewall" => &["enabled", "service", "zone"],
766 "CreateUser" => &["home", "shell", "user", "username"],
767 "AddUserToGroup" | "RemoveUserFromGroup" => &["group", "user", "username"],
768 "CreateGroup" => &["group", "system"],
769 "DeleteGroup" => &["group"],
770 "AddAuthorizedKey" | "RemoveAuthorizedKey" => &["public_key", "user", "username"],
771 "SetSshdOption" => &["option", "value"],
772 "SnapInstall" => &["auto_update", "channel", "name"],
773 "GrubSetKargs" => &["append", "delete"],
774 "UfwAllow" | "UfwDeny" => &["port_or_service"],
775 "UfwStatus" => &["numbered"],
776 "DistroboxCreate" => &["image", "name"],
777 "UfwDeleteRule" => &["rule_number"],
778 "UfwLimit" => &["target"],
779 "ProAttach" => &["token"],
780 "EnableProService" | "DisableProService" => &["service"],
781 "AppArmorEnforce" | "AppArmorComplain" => &["profile_path"],
782 "Fail2banStatus" => &["jail"],
783 "Fail2banBanIp" | "Fail2banUnbanIp" => &["ip", "jail"],
784 "ConfigureFail2banJail" => &["bantime", "enabled", "findtime", "maxretry", "name"],
785 "AddAuditRule" => &["key", "path", "perms"],
786 "ObtainCertificate" => &["challenge", "domain", "domains", "email"],
787 "ListJobHistory" => &[
788 "limit",
789 "status_filter",
790 "action_filter",
791 "since_hours",
792 "since",
793 ],
794 _ => return None,
795 })
796}
797
798pub(crate) fn validate_action_params(
799 action_name: &str,
800 params: &Value,
801) -> Result<(), ExecutorError> {
802 let allowed = allowed_action_params(action_name)
803 .ok_or_else(|| ExecutorError::UnknownAction(action_name.to_string()))?;
804 let object = params
805 .as_object()
806 .ok_or(ExecutorError::InvalidParam("params"))?;
807 if object.keys().any(|key| !allowed.contains(&key.as_str())) {
808 return Err(ExecutorError::InvalidParam("unknown parameter"));
809 }
810 Ok(())
811}
812
813pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
819 validate_action_params(action_name, params)?;
820 match action_name {
821 "GetSystemState" => Ok(deployment::get_system_state()),
823 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
824 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
825 "ListDeployments" => Ok(deployment::list_deployments()),
826 "UpdateSystem" => Ok(deployment::update_system()),
827 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
828 "RebootSystem" => Ok(deployment::reboot_system()),
829 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
830 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
831
832 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
834 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
835 "RebaseSystem" => {
836 let target_ref = require_str(params, "target_ref")?;
837 let target_ref = validated_rebase_ref(target_ref, "target_ref")?;
838 Ok(deployment::rebase_system(&target_ref))
839 }
840 "SetKernelArguments" => {
841 let add = str_array_or_empty(params, "add")?;
842 let remove = str_array_or_empty(params, "remove")?;
843 for arg in add.iter() {
846 validated_safe_kernel_arg(arg, "add")?;
847 }
848 for arg in remove.iter() {
851 validated_safe_kernel_arg_removal(arg, "remove")?;
852 }
853 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
854 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
855 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
856 }
857
858 "ListFlatpakRemotes" => {
864 let username = validated_username(resolve_username(params)?, "username")?;
865 Ok(flatpak::list_flatpak_remotes(&username))
866 }
867 "InstallFlatpak" => {
868 let username = validated_username(resolve_username(params)?, "username")?;
869 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
870 let remote = params
874 .get("remote")
875 .and_then(|v| v.as_str())
876 .unwrap_or("flathub");
877 let remote = validated_safe_arg(remote, "remote")?;
878 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
879 }
880 "RemoveFlatpak" => {
881 let username = validated_username(resolve_username(params)?, "username")?;
882 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
883 Ok(flatpak::remove_flatpak(&username, &app_id))
884 }
885 "SearchFlatpakApps" => {
886 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
887 Ok(flatpak::search_flatpak_apps(&term))
888 }
889 "AddFlatpakRemote" => {
890 let username = validated_username(resolve_username(params)?, "username")?;
891 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
892 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
893 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
894 }
895 "RemoveFlatpakRemote" => {
896 let username = validated_username(resolve_username(params)?, "username")?;
897 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
898 Ok(flatpak::remove_flatpak_remote(&username, &remote))
899 }
900 "GetFlatpakAppInfo" => {
901 let username = validated_username(resolve_username(params)?, "username")?;
902 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
903 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
904 }
905 "ListInstalledFlatpaks" => {
906 let username = validated_username(resolve_username(params)?, "username")?;
907 Ok(flatpak::list_installed_flatpaks(&username))
908 }
909 "UpdateFlatpak" => {
910 let username = validated_username(resolve_username(params)?, "username")?;
911 let app_id = params
914 .get("app_id")
915 .and_then(|v| v.as_str())
916 .filter(|id| !id.is_empty())
917 .map(|id| validated_safe_arg(id, "app_id"))
918 .transpose()?;
919 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
920 }
921
922 "ListContainers" => {
928 let username = validated_username(resolve_username(params)?, "username")?;
929 Ok(containers::list_containers(&username))
930 }
931 "CreateContainer" => {
932 let username = validated_username(resolve_username(params)?, "username")?;
933 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
934 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
935 Ok(containers::create_container(&username, &name, &image))
936 }
937 "StartContainer" => {
938 let username = validated_username(resolve_username(params)?, "username")?;
939 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
940 Ok(containers::start_container(&username, &name))
941 }
942 "StopContainer" => {
943 let username = validated_username(resolve_username(params)?, "username")?;
944 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
945 Ok(containers::stop_container(&username, &name))
946 }
947 "RemoveContainer" => {
948 let username = validated_username(resolve_username(params)?, "username")?;
949 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
950 Ok(containers::remove_container(&username, &name))
951 }
952 "GetContainerInfo" => {
953 let username = validated_username(resolve_username(params)?, "username")?;
954 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
955 Ok(containers::get_container_info(&username, &name))
956 }
957
958 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
960 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
961 "InstallPackages" => {
962 let pkgs = str_array_or_empty(params, "packages")?;
963 let validated: Vec<String> = pkgs
964 .iter()
965 .map(|p| validated_install_package(p, "packages"))
966 .collect::<Result<_, _>>()?;
967 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
968 Ok(layering::install_packages(&refs))
969 }
970 "RemovePackages" => {
971 let pkgs = str_array_or_empty(params, "packages")?;
972 let validated: Vec<String> = pkgs
973 .iter()
974 .map(|p| validated_install_package(p, "packages"))
975 .collect::<Result<_, _>>()?;
976 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
977 Ok(layering::remove_packages(&refs))
978 }
979 "AddLayeredPackage" => {
980 let package = validated_install_package(require_str(params, "package")?, "package")?;
981 Ok(layering::add_layered_package(&package))
982 }
983 "RemoveLayeredPackage" => {
984 let package = validated_install_package(require_str(params, "package")?, "package")?;
985 Ok(layering::remove_layered_package(&package))
986 }
987 "ReplaceLayeredPackage" => {
988 let old = validated_install_package(require_str(params, "old")?, "old")?;
989 let new = validated_install_package(require_str(params, "new")?, "new")?;
990 Ok(layering::replace_layered_package(&old, &new))
991 }
992 "RemoveBasePackage" => {
993 let package = validated_install_package(require_str(params, "package")?, "package")?;
994 Ok(layering::remove_base_package(&package))
995 }
996 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
997
998 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
1000 "AddPackageRepository" => Ok(package_repos::add_package_repository(
1001 validated_repo_id(params)?,
1002 validated_no_newline(params, "repo_url")?,
1003 )),
1004 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
1005 validated_repo_id(params)?,
1006 )),
1007 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
1008 validated_repo_id(params)?,
1009 )),
1010 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
1011 validated_repo_id(params)?,
1012 )),
1013
1014 "ListServices" => Ok(services::list_services()),
1016 "StartService" => {
1017 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1018 Ok(services::start_service(&unit))
1019 }
1020 "StopService" => {
1021 let unit = validated_stoppable_unit(require_str(params, "unit")?, "unit")?;
1022 Ok(services::stop_service(&unit))
1023 }
1024 "RestartService" => {
1025 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1027 let unit = validated_stoppable_unit(&unit, "unit")?;
1028 Ok(services::restart_service(&unit))
1029 }
1030 "SetServiceEnabled" => {
1031 let enabled = require_bool(params, "enabled")?;
1032 let unit = if enabled {
1035 validated_activatable_unit(require_str(params, "unit")?, "unit")?
1036 } else {
1037 validated_unit_name(require_str(params, "unit")?, "unit")?
1038 };
1039 Ok(services::set_service_enabled(&unit, enabled))
1040 }
1041 "MaskService" => {
1042 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1043 Ok(services::mask_service(&unit))
1044 }
1045 "UnmaskService" => {
1046 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1047 Ok(services::unmask_service(&unit))
1048 }
1049 "GetServiceLogs" => {
1050 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1051 Ok(services::get_service_logs(&unit))
1052 }
1053 "GetServiceStatus" => {
1054 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1055 Ok(services::get_service_status(&unit))
1056 }
1057 "ReloadService" => {
1058 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1059 Ok(services::reload_service(&unit))
1060 }
1061 "ListTimers" => Ok(services::list_timers()),
1062 "ReloadDaemon" => Ok(services::reload_daemon()),
1063 "CreateScheduledJob" => {
1064 let name = require_str(params, "name")?;
1067 if services::scheduled_job_unit_paths(name).is_none() {
1068 return Err(ExecutorError::InvalidParam("name"));
1069 }
1070 let command = require_str(params, "command")?;
1073 if command.is_empty()
1074 || command.len() > MAX_SCHEDULED_COMMAND_LEN
1075 || command.chars().any(|c| c.is_control())
1076 {
1077 return Err(ExecutorError::InvalidParam("command"));
1078 }
1079 let schedule = require_str(params, "schedule")?;
1082 if schedule.is_empty()
1083 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
1084 || !schedule.chars().all(|c| {
1085 c.is_ascii_alphanumeric()
1086 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
1087 })
1088 {
1089 return Err(ExecutorError::InvalidParam("schedule"));
1090 }
1091 Ok(services::create_scheduled_job(name, command, schedule))
1092 }
1093 "GetServiceResourceLimits" => {
1094 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1095 Ok(services::get_service_resource_limits(&unit))
1096 }
1097 "SetServiceResourceLimits" => {
1098 let unit = validated_resource_limit_unit(require_str(params, "unit")?, "unit")?;
1102 let mut assignments = Vec::new();
1105 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
1106 assignments.push(format!("MemoryMax={v}"));
1107 }
1108 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
1109 assignments.push(format!("MemoryHigh={v}"));
1110 }
1111 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
1112 assignments.push(format!("CPUQuota={v}"));
1113 }
1114 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
1115 assignments.push(format!("TasksMax={v}"));
1116 }
1117 if assignments.is_empty() {
1118 return Err(ExecutorError::MissingParam("memory_max"));
1119 }
1120 Ok(services::set_service_resource_limits(&unit, &assignments))
1121 }
1122
1123 "ListToolboxes" => {
1127 let username = validated_username(resolve_username(params)?, "username")?;
1128 Ok(toolbox::list_toolboxes(&username))
1129 }
1130 "CreateToolbox" => {
1131 let username = validated_username(resolve_username(params)?, "username")?;
1132 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1133 let image = params
1134 .get("image")
1135 .and_then(|v| v.as_str())
1136 .map(|img| validated_safe_arg(img, "image"))
1137 .transpose()?;
1138 let release = params
1139 .get("release")
1140 .and_then(|v| v.as_str())
1141 .map(|r| validated_safe_arg(r, "release"))
1142 .transpose()?;
1143 Ok(toolbox::create_toolbox(
1144 &username,
1145 &name,
1146 release.as_deref(),
1147 image.as_deref(),
1148 ))
1149 }
1150 "RemoveToolbox" => {
1151 let username = validated_username(resolve_username(params)?, "username")?;
1152 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1153 Ok(toolbox::remove_toolbox(&username, &name))
1154 }
1155
1156 "GetDateTime" => Ok(identity::get_datetime()),
1158 "SetHostname" => {
1159 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
1160 Ok(identity::set_hostname(&hostname))
1161 }
1162 "SetTimezone" => {
1163 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
1164 Ok(identity::set_timezone(&timezone))
1165 }
1166 "SetLocale" => {
1167 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
1168 Ok(identity::set_locale(&locale))
1169 }
1170 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
1171
1172 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
1174
1175 "ListProcesses" => Ok(processes::list_processes_spec()),
1177 "SignalProcess" => {
1178 let pid = params
1180 .get("pid")
1181 .and_then(|v| {
1182 v.as_u64()
1183 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
1184 })
1185 .ok_or(ExecutorError::MissingParam("pid"))?;
1186 if pid < 2 || pid > u32::MAX as u64 {
1189 return Err(ExecutorError::InvalidParam("pid"));
1190 }
1191 let signal = validated_kill_signal(
1192 params
1193 .get("signal")
1194 .and_then(|v| v.as_str())
1195 .unwrap_or("TERM"),
1196 )?;
1197 Ok(processes::signal_process(pid as u32, signal))
1198 }
1199
1200 "GetJournalLog" => {
1202 let unit = optional_validated(params, "unit", validated_unit_name)?;
1203 let priority = optional_validated(params, "priority", validated_journal_priority)?;
1204 let since = optional_validated(params, "since", validated_journal_time)?;
1205 let until = optional_validated(params, "until", validated_journal_time)?;
1206 let grep = optional_validated(params, "grep", validated_journal_grep)?;
1207 let lines = params
1210 .get("lines")
1211 .and_then(|v| v.as_u64())
1212 .unwrap_or(100)
1213 .clamp(1, MAX_JOURNAL_LINES) as u32;
1214 let boot = params
1215 .get("boot")
1216 .and_then(|v| v.as_bool())
1217 .unwrap_or(false);
1218 let kernel = params
1219 .get("kernel")
1220 .and_then(|v| v.as_bool())
1221 .unwrap_or(false);
1222 Ok(journald::get_journal_log(&journald::JournalQuery {
1223 lines,
1224 unit: unit.as_deref(),
1225 priority: priority.as_deref(),
1226 boot,
1227 kernel,
1228 since: since.as_deref(),
1229 until: until.as_deref(),
1230 grep: grep.as_deref(),
1231 }))
1232 }
1233 "VacuumJournal" => {
1234 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1236 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1237 match (size_mb, retain_days) {
1238 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1239 Ok(journald::vacuum_journal_by_size(mb as u32))
1240 }
1241 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1242 Ok(journald::vacuum_journal_by_time(days as u32))
1243 }
1244 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1245 _ => Err(ExecutorError::InvalidParam("size_mb")),
1247 }
1248 }
1249
1250 "GetLvmReport" => Ok(lvm::get_lvm_report()),
1252 "ExtendLogicalVolume" => {
1253 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1254 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1255 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1256 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1257 }
1258 "CreateLogicalVolume" => {
1259 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1260 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1261 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1262 Ok(lvm::create_logical_volume(&vg, &name, &size))
1263 }
1264 "CreateLvSnapshot" => {
1265 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1266 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1267 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1268 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1269 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1270 }
1271
1272 "GetSysctl" => {
1274 let key = optional_validated(params, "key", validated_sysctl_key)?;
1276 Ok(sysctl::get_sysctl(key.as_deref()))
1277 }
1278 "SetSysctl" => {
1279 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1280 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1281 Ok(sysctl::set_sysctl(&key, &value))
1282 }
1283
1284 "GetMounts" => Ok(mounts::get_mounts()),
1286 "AddMount" => {
1287 let device = validated_mount_device(require_str(params, "device")?, "device")?;
1288 let mountpoint =
1289 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1290 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1291 let options = optional_validated(params, "options", validated_mount_options)?;
1292 Ok(mounts::add_mount(
1293 &device,
1294 &mountpoint,
1295 &fstype,
1296 options.as_deref(),
1297 ))
1298 }
1299 "RemoveMount" => {
1300 let mountpoint =
1301 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1302 Ok(mounts::remove_mount(&mountpoint))
1303 }
1304 "AddSwap" => {
1305 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1306 let size_mb = require_u32(params, "size_mb")?;
1307 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1309 return Err(ExecutorError::InvalidParam("size_mb"));
1310 }
1311 Ok(mounts::add_swap(&file, size_mb))
1312 }
1313 "RemoveSwap" => {
1314 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1315 Ok(mounts::remove_swap(&file))
1316 }
1317
1318 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1320 "GrantSudoAccess" => {
1321 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1322 let user = validated_username(require_str(params, "user")?, "user")?;
1323 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1324 let runas = match params
1326 .get("runas")
1327 .and_then(|v| v.as_str())
1328 .filter(|s| !s.is_empty())
1329 {
1330 Some("ALL") => Some("ALL".to_string()),
1331 Some(u) => Some(validated_username(u, "runas")?),
1332 None => None,
1333 };
1334 let nopasswd = params
1335 .get("nopasswd")
1336 .and_then(|v| v.as_bool())
1337 .unwrap_or(false);
1338 let shell_equivalent = shell_equivalent_sudo_command(&commands);
1357 if (commands == "ALL" || shell_equivalent.is_some()) && nopasswd {
1358 return Err(ExecutorError::InvalidParam("commands"));
1359 }
1360 Ok(sudoers::grant_sudo_access(
1361 &name,
1362 &user,
1363 &commands,
1364 runas.as_deref(),
1365 nopasswd,
1366 ))
1367 }
1368 "RevokeSudoAccess" => {
1369 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1370 Ok(sudoers::revoke_sudo_access(&name))
1371 }
1372
1373 "GetAptPins" => {
1375 let package = optional_validated(params, "package", validated_apt_package)?;
1376 Ok(apt_preferences::get_apt_pins(package.as_deref()))
1377 }
1378 "SetAptPin" => {
1379 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1380 let package = validated_apt_package(require_str(params, "package")?, "package")?;
1381 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1382 let priority = params
1383 .get("priority")
1384 .and_then(|v| v.as_i64())
1385 .ok_or(ExecutorError::MissingParam("priority"))?;
1386 if !(-1..=1000).contains(&priority) {
1387 return Err(ExecutorError::InvalidParam("priority"));
1388 }
1389 Ok(apt_preferences::set_apt_pin(
1390 &name, &package, &pin, priority,
1391 ))
1392 }
1393 "RemoveAptPin" => {
1394 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1395 Ok(apt_preferences::remove_apt_pin(&name))
1396 }
1397
1398 "GetLogrotateStatus" => {
1400 let config = optional_validated(params, "config", validated_log_path)?;
1401 Ok(logging::get_logrotate_status(config.as_deref()))
1402 }
1403 "ConfigureLogRotation" => {
1404 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1405 let frequency = require_str(params, "frequency")?;
1406 if !matches!(frequency, "daily" | "weekly" | "monthly") {
1407 return Err(ExecutorError::InvalidParam("frequency"));
1408 }
1409 let rotate = require_u32(params, "rotate")?;
1410 if rotate > 1000 {
1411 return Err(ExecutorError::InvalidParam("rotate"));
1412 }
1413 let path = validated_log_rotation(require_str(params, "path")?, rotate)?;
1414 let compress = params
1415 .get("compress")
1416 .and_then(|v| v.as_bool())
1417 .unwrap_or(false);
1418 Ok(logging::configure_log_rotation(
1419 &name, &path, frequency, rotate, compress,
1420 ))
1421 }
1422 "RemoveLogRotation" => {
1423 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1424 Ok(logging::remove_log_rotation(&name))
1425 }
1426 "ConfigureRemoteSyslog" => {
1427 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1428 let port = require_u32(params, "port")?;
1429 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1430 return Err(ExecutorError::InvalidParam("port"));
1431 }
1432 let protocol = require_str(params, "protocol")?;
1433 if !matches!(protocol, "tcp" | "udp") {
1434 return Err(ExecutorError::InvalidParam("protocol"));
1435 }
1436 Ok(logging::configure_remote_syslog(
1437 &host,
1438 port as u16,
1439 protocol,
1440 ))
1441 }
1442 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1443
1444 "GetPasswordAging" => {
1446 let user = validated_username(require_str(params, "user")?, "user")?;
1447 Ok(pam::get_password_aging(&user))
1448 }
1449 "SetPasswordAging" => {
1450 let user = validated_username(require_str(params, "user")?, "user")?;
1451 let mut flags = Vec::new();
1453 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1454 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1455 if n > MAX_PASSWORD_AGE_DAYS {
1456 return Err(ExecutorError::InvalidParam(key));
1457 }
1458 flags.push(opt.to_string());
1459 flags.push(n.to_string());
1460 }
1461 }
1462 if flags.is_empty() {
1463 return Err(ExecutorError::MissingParam("max_days"));
1464 }
1465 Ok(pam::set_password_aging(&user, &flags))
1466 }
1467 "SetPasswordPolicy" => {
1468 let mut extra = Vec::new();
1471 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1472 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1473 return Err(ExecutorError::InvalidParam("minlen"));
1474 }
1475 extra.push("--minlen".to_string());
1476 extra.push(n.to_string());
1477 }
1478 for (key, flag) in [
1479 ("dcredit", "--dcredit"),
1480 ("ucredit", "--ucredit"),
1481 ("lcredit", "--lcredit"),
1482 ("ocredit", "--ocredit"),
1483 ] {
1484 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1485 if !(-64..=64).contains(&n) {
1486 return Err(ExecutorError::InvalidParam(key));
1487 }
1488 extra.push(flag.to_string());
1489 extra.push(n.to_string());
1490 }
1491 }
1492 if extra.is_empty() {
1493 return Err(ExecutorError::MissingParam("minlen"));
1494 }
1495 Ok(pam::set_password_policy(&extra))
1496 }
1497 "SetAccountLockout" => {
1498 let mut extra = Vec::new();
1500 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1501 if !(1..=1000).contains(&n) {
1502 return Err(ExecutorError::InvalidParam("deny"));
1503 }
1504 extra.push("--deny".to_string());
1505 extra.push(n.to_string());
1506 }
1507 for (key, flag) in [
1508 ("unlock_time", "--unlock-time"),
1509 ("fail_interval", "--fail-interval"),
1510 ] {
1511 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1512 if n > MAX_LOCKOUT_WINDOW_SECS {
1513 return Err(ExecutorError::InvalidParam(key));
1514 }
1515 extra.push(flag.to_string());
1516 extra.push(n.to_string());
1517 }
1518 }
1519 if extra.is_empty() {
1520 return Err(ExecutorError::MissingParam("deny"));
1521 }
1522 Ok(pam::set_account_lockout(&extra))
1523 }
1524
1525 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1527 "GetHostState" => Ok(system_info::get_host_state()),
1528
1529 "GetFirewallState" => Ok(network::get_firewall_state()),
1531 "GetNetworkStatus" => Ok(network::get_network_status()),
1532 "GetListeningPorts" => Ok(network::get_listening_ports()),
1533 "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1534 "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1535 "ConfigureWifi" => {
1536 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1537 let password = params
1539 .get("password")
1540 .and_then(|v| v.as_str())
1541 .filter(|p| !p.is_empty())
1542 .map(|p| validated_safe_arg(p, "password"))
1543 .transpose()?;
1544 Ok(network::configure_wifi(&ssid, password.as_deref()))
1545 }
1546 "SetDnsServers" => {
1547 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1548 let servers = str_array_or_empty(params, "servers")?;
1549 let validated: Vec<String> = servers
1550 .iter()
1551 .map(|s| validated_safe_arg(s, "servers"))
1552 .collect::<Result<_, _>>()?;
1553 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1554 Ok(network::set_dns_servers(&interface, &refs))
1555 }
1556 "ConfigureFirewall" => {
1557 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1558 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1559 Ok(network::configure_firewall(
1560 &zone,
1561 &service,
1562 require_bool(params, "enabled")?,
1563 ))
1564 }
1565
1566 "ListUsers" => Ok(users::list_users()),
1568 "ListGroups" => Ok(users::list_groups()),
1569 "CreateUser" => {
1570 let username = validated_username(resolve_username(params)?, "username")?;
1571 let shell = params
1572 .get("shell")
1573 .and_then(|v| v.as_str())
1574 .map(|s| validated_safe_arg(s, "shell"))
1575 .transpose()?;
1576 let home = params
1577 .get("home")
1578 .and_then(|v| v.as_str())
1579 .map(|h| validated_home_dir(h, "home"))
1580 .transpose()?;
1581 Ok(users::create_user(
1582 &username,
1583 shell.as_deref(),
1584 home.as_deref(),
1585 ))
1586 }
1587 "DeleteUser" => {
1588 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1589 Ok(users::delete_user(&username))
1590 }
1591 "AddUserToGroup" => {
1592 let username = validated_username(resolve_username(params)?, "username")?;
1593 let group = validated_group(require_str(params, "group")?, "group")?;
1594 Ok(users::add_user_to_group(&username, &group))
1595 }
1596 "RemoveUserFromGroup" => {
1597 let username = validated_username(resolve_username(params)?, "username")?;
1598 let group = validated_group(require_str(params, "group")?, "group")?;
1599 Ok(users::remove_user_from_group(&username, &group))
1600 }
1601 "CreateGroup" => {
1602 let group = validated_group(require_str(params, "group")?, "group")?;
1603 let system = params
1604 .get("system")
1605 .and_then(|v| v.as_bool())
1606 .unwrap_or(false);
1607 Ok(users::create_group(&group, system))
1608 }
1609 "DeleteGroup" => {
1610 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1611 Ok(users::delete_group(&group))
1612 }
1613 "LockUserAccount" => {
1614 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1615 Ok(users::lock_user_account(&username))
1616 }
1617 "UnlockUserAccount" => {
1618 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1619 Ok(users::unlock_user_account(&username))
1620 }
1621
1622 "GetAuthorizedKeys" => {
1624 let username = validated_username(resolve_username(params)?, "username")?;
1625 Ok(ssh::get_authorized_keys(&username))
1626 }
1627 "AddAuthorizedKey" => {
1628 let username = validated_username(resolve_username(params)?, "username")?;
1629 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1630 Ok(ssh::add_authorized_key(&username, &public_key))
1631 }
1632 "RemoveAuthorizedKey" => {
1633 let username = validated_username(resolve_username(params)?, "username")?;
1634 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1635 Ok(ssh::remove_authorized_key(&username, &public_key))
1636 }
1637 "SetSshdOption" => {
1638 let option = require_str(params, "option")?;
1642 let value = require_str(params, "value")?;
1643 let allowed_values: &[&str] = match option {
1644 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1645 "PasswordAuthentication"
1646 | "PubkeyAuthentication"
1647 | "X11Forwarding"
1648 | "PermitEmptyPasswords" => &["yes", "no"],
1649 _ => return Err(ExecutorError::InvalidParam("option")),
1650 };
1651 if !allowed_values.contains(&value) {
1652 return Err(ExecutorError::InvalidParam("value"));
1653 }
1654 Ok(ssh::set_sshd_option(option, value))
1655 }
1656
1657 "AptUpdate" => Ok(apt::apt_update()),
1659 "AptUpgrade" => Ok(apt::apt_upgrade()),
1660 "AptInstall" => {
1661 let package = validated_install_package(require_str(params, "package")?, "package")?;
1662 Ok(apt::apt_install(&package))
1663 }
1664 "AptRemove" => {
1665 let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1666 Ok(apt::apt_remove(&package))
1667 }
1668 "AptPurge" => {
1669 let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1670 Ok(apt::apt_purge(&package))
1671 }
1672 "AptAutoremove" => Ok(apt::apt_autoremove()),
1673 "AptHold" => {
1674 let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1675 Ok(apt::apt_hold(&package))
1676 }
1677 "AptUnhold" => {
1678 let package = validated_unmodified_package(require_str(params, "package")?, "package")?;
1679 Ok(apt::apt_unhold(&package))
1680 }
1681 "AptSearch" => {
1682 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1683 Ok(apt::apt_search(&term))
1684 }
1685 "AptListInstalled" => Ok(apt::apt_list_installed()),
1686 "AptShow" => {
1687 let package = validated_apt_show_package(require_str(params, "package")?, "package")?;
1688 Ok(apt::apt_show(&package))
1689 }
1690 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1691 "AptHistoryList" => Ok(apt::apt_history_list()),
1692 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1693 params, "enabled",
1694 )?)),
1695
1696 "AddPpa" => {
1698 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1699 Ok(ppa::add_ppa(&name))
1700 }
1701 "RemovePpa" => {
1702 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1703 Ok(ppa::remove_ppa(&name))
1704 }
1705
1706 "SnapInstall" => {
1708 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1709 let channel = params
1710 .get("channel")
1711 .and_then(|v| v.as_str())
1712 .filter(|s| !s.is_empty())
1713 .map(|s| validated_safe_arg(s, "channel"))
1714 .transpose()?;
1715 let auto_update = params
1716 .get("auto_update")
1717 .and_then(|v| v.as_bool())
1718 .unwrap_or(false);
1719 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1720 snap::SnapInstallError::InvalidArg { param, .. } => {
1721 ExecutorError::InvalidParam(param)
1722 }
1723 })
1724 }
1725 "SnapRemove" => {
1726 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1727 Ok(snap::snap_remove(&name))
1728 }
1729 "SnapRefresh" => {
1730 let name = params
1731 .get("name")
1732 .and_then(|v| v.as_str())
1733 .filter(|s| !s.is_empty())
1734 .map(|s| validated_safe_arg(s, "name"))
1735 .transpose()?;
1736 Ok(snap::snap_refresh(name.as_deref()))
1737 }
1738 "SnapHold" => {
1739 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1740 Ok(snap::snap_hold(&name))
1741 }
1742 "SnapUnhold" => {
1743 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1744 Ok(snap::snap_unhold(&name))
1745 }
1746 "SnapList" => Ok(snap::snap_list()),
1747 "SnapInfo" => {
1748 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1749 Ok(snap::snap_info(&name))
1750 }
1751 "SnapRevert" => {
1752 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1753 Ok(snap::snap_revert(&name))
1754 }
1755 "SnapClassicInstall" => {
1756 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1757 Ok(snap::snap_classic_install(&name))
1758 }
1759
1760 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1762 "GrubSetKargs" => {
1763 let append: Vec<String> = params
1764 .get("append")
1765 .and_then(|v| v.as_array())
1766 .map(|arr| {
1767 arr.iter()
1768 .filter_map(|v| v.as_str())
1769 .map(String::from)
1770 .collect()
1771 })
1772 .unwrap_or_default();
1773 let delete: Vec<String> = params
1774 .get("delete")
1775 .and_then(|v| v.as_array())
1776 .map(|arr| {
1777 arr.iter()
1778 .filter_map(|v| v.as_str())
1779 .map(String::from)
1780 .collect()
1781 })
1782 .unwrap_or_default();
1783 for a in &append {
1792 validated_safe_arg(a, "append")?;
1793 validated_safe_kernel_arg(a, "append")?;
1794 }
1795 for d in &delete {
1796 validated_safe_arg(d, "delete")?;
1797 validated_safe_kernel_arg_removal(d, "delete")?;
1798 }
1799 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1802 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1803 grub::grub_set_kargs(&append_refs, &delete_refs)
1804 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1805 }
1806
1807 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1809
1810 "UfwEnable" => Ok(ufw::ufw_enable()),
1812 "UfwDisable" => Ok(ufw::ufw_disable()),
1813 "UfwAllow" => {
1814 let port_or_service = validated_port_or_service(
1815 require_str(params, "port_or_service")?,
1816 "port_or_service",
1817 )?;
1818 Ok(ufw::ufw_allow(&port_or_service))
1819 }
1820 "UfwDeny" => {
1821 let port_or_service = validated_port_or_service(
1822 require_str(params, "port_or_service")?,
1823 "port_or_service",
1824 )?;
1825 Ok(ufw::ufw_deny(&port_or_service))
1826 }
1827 "UfwReset" => Ok(ufw::ufw_reset()),
1828 "UfwStatus" => {
1829 let numbered = match params.get("numbered") {
1830 None => false,
1831 Some(value) => value
1832 .as_bool()
1833 .ok_or(ExecutorError::InvalidParam("numbered"))?,
1834 };
1835 Ok(ufw::ufw_status_with_numbered(numbered))
1836 }
1837
1838 "DistroboxList" => Ok(distrobox::distrobox_list()),
1840 "DistroboxCreate" => {
1841 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1842 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1843 Ok(distrobox::distrobox_create(&name, &image))
1844 }
1845 "DistroboxRemove" => {
1846 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1847 Ok(distrobox::distrobox_remove(&name))
1848 }
1849
1850 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1852 "NetplanApply" => Ok(netplan::netplan_apply()),
1853 "NetplanSet" => {
1854 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1855 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1856 Ok(netplan::netplan_set(&key, &value))
1857 }
1858 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1859
1860 "UfwDeleteRule" => {
1862 let rule_number = require_positive_u32(params, "rule_number")?;
1863 ufw::ufw_delete_rule(rule_number)
1864 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1865 }
1866 "UfwLimit" => {
1867 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1868 Ok(ufw::ufw_limit(&target))
1869 }
1870
1871 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1873 "ProAttach" => {
1874 let token = require_str(params, "token")?;
1876 let token = validated_safe_arg(token, "token")?;
1878 Ok(ubuntu_pro::pro_attach(&token))
1879 }
1880 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1881 "EnableProService" => {
1882 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1883 Ok(ubuntu_pro::enable_pro_service(&service))
1884 }
1885 "DisableProService" => {
1886 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1887 Ok(ubuntu_pro::disable_pro_service(&service))
1888 }
1889
1890 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1892
1893 "MultipassList" => Ok(multipass::multipass_list()),
1895
1896 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1898
1899 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1901 "ResolvectlSetDns" => {
1902 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1903 let raw_servers: Vec<String> = params
1904 .get("servers")
1905 .and_then(|v| v.as_array())
1906 .map(|arr| {
1907 arr.iter()
1908 .filter_map(|v| v.as_str())
1909 .map(String::from)
1910 .collect()
1911 })
1912 .unwrap_or_default();
1913 if raw_servers.is_empty() {
1914 return Err(ExecutorError::MissingParam("servers"));
1915 }
1916 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1920 for s in &raw_servers {
1921 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1922 param: "servers",
1923 value: s.clone(),
1924 })?;
1925 parsed_servers.push(addr);
1926 }
1927 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1928 }
1929
1930 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1932 "AppArmorEnforce" => {
1933 let profile_path =
1934 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1935 Ok(apparmor::apparmor_enforce(&profile_path))
1936 }
1937 "AppArmorComplain" => {
1938 let profile_path =
1939 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1940 Ok(apparmor::apparmor_complain(&profile_path))
1941 }
1942
1943 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1945
1946 "UbuntuInstallFlatpak" => {
1948 let username = validated_username(resolve_username(params)?, "username")?;
1949 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1950 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1951 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1952 }
1953 "UbuntuRemoveFlatpak" => {
1954 let username = validated_username(resolve_username(params)?, "username")?;
1955 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1956 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1957 }
1958 "UbuntuUpdateFlatpak" => {
1959 let username = validated_username(resolve_username(params)?, "username")?;
1960 let app_id = params
1961 .get("app_id")
1962 .and_then(|v| v.as_str())
1963 .filter(|s| !s.is_empty())
1964 .map(|s| validated_safe_arg(s, "app_id"))
1965 .transpose()?;
1966 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1967 }
1968 "UbuntuListFlatpaks" => {
1969 let username = validated_username(resolve_username(params)?, "username")?;
1970 Ok(flatpak::ubuntu_list_flatpaks(&username))
1971 }
1972
1973 "Fail2banStatus" => {
1975 let jail = params
1976 .get("jail")
1977 .and_then(|v| v.as_str())
1978 .filter(|s| !s.is_empty())
1979 .map(|s| validated_safe_arg(s, "jail"))
1980 .transpose()?;
1981 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1982 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1983 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1984 })
1985 }
1986 "Fail2banBanIp" => {
1987 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1988 let ip = require_str(params, "ip")?;
1989 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1990 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1991 param: "ip",
1992 value: v,
1993 },
1994 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1995 })
1996 }
1997 "Fail2banUnbanIp" => {
1998 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1999 let ip = require_str(params, "ip")?;
2000 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
2001 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
2002 param: "ip",
2003 value: v,
2004 },
2005 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
2006 })
2007 }
2008 "ConfigureFail2banJail" => {
2009 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
2010 let mut extra = Vec::new();
2011 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
2012 extra.push("--enabled".to_string());
2013 extra.push(if n { "true" } else { "false" }.to_string());
2014 }
2015 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
2016 if !(1..=100).contains(&n) {
2017 return Err(ExecutorError::InvalidParam("maxretry"));
2018 }
2019 extra.push("--maxretry".to_string());
2020 extra.push(n.to_string());
2021 }
2022 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
2023 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
2024 if n > MAX_FAIL2BAN_WINDOW_SECS {
2025 return Err(ExecutorError::InvalidParam(key));
2026 }
2027 extra.push(flag.to_string());
2028 extra.push(n.to_string());
2029 }
2030 }
2031 if extra.is_empty() {
2032 return Err(ExecutorError::MissingParam("maxretry"));
2033 }
2034 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
2035 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
2036 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
2037 })
2038 }
2039
2040 "GetAuditRules" => Ok(auditd::get_audit_rules()),
2042 "AddAuditRule" => {
2043 let path = validated_audit_path(require_str(params, "path")?, "path")?;
2044 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
2045 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2046 Ok(auditd::add_audit_rule(&path, &perms, &key))
2047 }
2048 "RemoveAuditRule" => {
2049 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2050 Ok(auditd::remove_audit_rule(&key))
2051 }
2052
2053 "GetCertificates" => Ok(certbot::get_certificates()),
2055 "ObtainCertificate" => {
2056 let mut domains = Vec::new();
2058 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
2059 for d in arr {
2060 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
2061 domains.push(validated_domain(s, "domains")?);
2062 }
2063 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
2064 domains.push(validated_domain(d, "domain")?);
2065 }
2066 if domains.is_empty() {
2067 return Err(ExecutorError::MissingParam("domains"));
2068 }
2069 let email = validated_email(require_str(params, "email")?, "email")?;
2070 let challenge = params
2071 .get("challenge")
2072 .and_then(|v| v.as_str())
2073 .unwrap_or("standalone");
2074 if !matches!(challenge, "standalone" | "nginx" | "apache") {
2075 return Err(ExecutorError::InvalidParam("challenge"));
2076 }
2077 Ok(certbot::obtain_certificate(&domains, &email, challenge))
2078 }
2079 "RenewCertificates" => Ok(certbot::renew_certificates()),
2080
2081 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
2082 }
2083}
2084
2085pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
2091 match &spec.mechanism {
2092 ActionMechanism::Command { program, args } => {
2093 let mut child = tokio::process::Command::new(program)
2097 .args(args)
2098 .stdin(Stdio::null())
2099 .process_group(0)
2102 .stdout(Stdio::piped())
2103 .stderr(Stdio::piped())
2104 .spawn()?;
2105
2106 let stdout_h = child.stdout.take().expect("stdout was piped");
2111 let stderr_h = child.stderr.take().expect("stderr was piped");
2112 let stdout_task = tokio::spawn(async move {
2113 let mut buf = Vec::new();
2114 BufReader::new(stdout_h)
2115 .read_to_end(&mut buf)
2116 .await
2117 .map(|_| buf)
2118 });
2119 let stderr_task = tokio::spawn(async move {
2120 let mut buf = Vec::new();
2121 BufReader::new(stderr_h)
2122 .read_to_end(&mut buf)
2123 .await
2124 .map(|_| buf)
2125 });
2126
2127 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
2128 Ok(Ok(status)) => status,
2129 Ok(Err(err)) => {
2133 return Err(kill_and_reap(
2134 &mut child,
2135 program,
2136 args,
2137 StopCause::Unreadable(err),
2138 )
2139 .await)
2140 }
2141 Err(_) => {
2142 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
2143 }
2144 };
2145 let join =
2146 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
2147 let stdout = stdout_task.await.map_err(join)??;
2148 let stderr = stderr_task.await.map_err(join)??;
2149
2150 Ok(ExecutionOutput {
2151 stdout: String::from_utf8_lossy(&stdout).into_owned(),
2152 stderr: String::from_utf8_lossy(&stderr).into_owned(),
2153 exit_code: status.code().unwrap_or(-1),
2154 })
2155 }
2156 ActionMechanism::FileScan { path } => {
2157 let mut entries = tokio::fs::read_dir(path).await?;
2158 let mut names = Vec::new();
2159 while let Some(entry) = entries.next_entry().await? {
2160 names.push(entry.file_name().to_string_lossy().into_owned());
2161 }
2162 names.sort();
2163 Ok(ExecutionOutput {
2164 stdout: names.join("\n"),
2165 stderr: String::new(),
2166 exit_code: 0,
2167 })
2168 }
2169 ActionMechanism::FileWrite { path, content } => {
2170 if let Some(parent) = std::path::Path::new(path).parent() {
2171 tokio::fs::create_dir_all(parent).await?;
2172 }
2173 tokio::fs::write(path, content).await?;
2174 Ok(ExecutionOutput {
2175 stdout: String::new(),
2176 stderr: String::new(),
2177 exit_code: 0,
2178 })
2179 }
2180 ActionMechanism::FilePatch {
2181 path,
2182 search,
2183 replace,
2184 } => {
2185 let content = tokio::fs::read_to_string(path).await?;
2186 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
2187 if patched == content && !search.is_empty() {
2188 return Ok(ExecutionOutput {
2189 stdout: String::new(),
2190 stderr: format!("search string not found in file: {}", path),
2191 exit_code: 1,
2192 });
2193 }
2194 tokio::fs::write(path, patched).await?;
2195 Ok(ExecutionOutput {
2196 stdout: String::new(),
2197 stderr: String::new(),
2198 exit_code: 0,
2199 })
2200 }
2201 ActionMechanism::FileDelete { path } => {
2202 tokio::fs::remove_file(path).await?;
2203 Ok(ExecutionOutput {
2204 stdout: String::new(),
2205 stderr: String::new(),
2206 exit_code: 0,
2207 })
2208 }
2209 }
2210}
2211
2212fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
2213 match params.get(key) {
2214 None => Err(ExecutorError::MissingParam(key)),
2215 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
2216 }
2217}
2218
2219fn optional_validated<F>(
2223 params: &Value,
2224 key: &'static str,
2225 validator: F,
2226) -> Result<Option<String>, ExecutorError>
2227where
2228 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2229{
2230 match params
2231 .get(key)
2232 .and_then(|v| v.as_str())
2233 .filter(|s| !s.is_empty())
2234 {
2235 Some(s) => Ok(Some(validator(s, key)?)),
2236 None => Ok(None),
2237 }
2238}
2239
2240fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2250 params
2251 .get("username")
2252 .or_else(|| params.get("user"))
2253 .ok_or(ExecutorError::MissingParam("username"))
2254 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2255}
2256
2257fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2261 let id = require_str(params, "repo_id")?;
2262 let valid = !id.is_empty()
2263 && id
2264 .chars()
2265 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2266 if valid {
2267 Ok(id)
2268 } else {
2269 Err(ExecutorError::InvalidParam("repo_id"))
2270 }
2271}
2272
2273fn validated_no_newline<'a>(
2276 params: &'a Value,
2277 key: &'static str,
2278) -> Result<&'a str, ExecutorError> {
2279 let val = require_str(params, key)?;
2280 if val.contains('\n') || val.contains('\r') {
2281 Err(ExecutorError::InvalidParam(key))
2282 } else {
2283 Ok(val)
2284 }
2285}
2286
2287fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2291 const MAX_LEN: usize = 8192;
2292 const ALLOWED_PREFIXES: &[&str] = &[
2293 "ssh-rsa",
2294 "ssh-ed25519",
2295 "ssh-ed25519-sk",
2296 "ecdsa-sha2-nistp256",
2297 "ecdsa-sha2-nistp384",
2298 "ecdsa-sha2-nistp521",
2299 "sk-ssh-ed25519",
2300 "sk-ecdsa-sha2-nistp256",
2301 ];
2302
2303 if s.is_empty() || s.len() > MAX_LEN {
2304 return Err(ExecutorError::InvalidParam("public_key"));
2305 }
2306 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2307 return Err(ExecutorError::InvalidParam("public_key"));
2308 }
2309 if s.chars().any(|c| {
2324 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2325 || !c.is_ascii()
2326 || c.is_ascii_control()
2327 }) {
2328 return Err(ExecutorError::InvalidParam("public_key"));
2329 }
2330 Ok(s.to_string())
2331}
2332
2333fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2334 match params.get(key) {
2335 None => Err(ExecutorError::MissingParam(key)),
2336 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2337 }
2338}
2339
2340fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2341 match params.get(key) {
2342 None => Err(ExecutorError::MissingParam(key)),
2343 Some(v) => {
2344 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2345 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2346 }
2347 }
2348}
2349
2350fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2354 let n = require_u32(params, key)?;
2355 if n == 0 {
2356 return Err(ExecutorError::InvalidParam(key));
2357 }
2358 Ok(n)
2359}
2360
2361fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2365 match params.get(key) {
2366 None | Some(Value::Null) => Ok(vec![]),
2367 Some(Value::Array(arr)) => arr
2368 .iter()
2369 .map(|v| {
2370 v.as_str()
2371 .map(String::from)
2372 .ok_or(ExecutorError::InvalidParam(key))
2373 })
2374 .collect(),
2375 _ => Err(ExecutorError::InvalidParam(key)),
2376 }
2377}
2378
2379const KARG_FLAGS: &[&str] = &[
2394 "quiet",
2395 "splash",
2396 "rhgb",
2397 "nomodeset",
2398 "nowatchdog",
2399 "threadirqs",
2400];
2401
2402const KARG_HARDENING_FLAGS: &[&str] = &["nosmt", "slab_nomerge"];
2404
2405const KARG_VALUE_KEYS: &[&str] = &[
2407 "console",
2408 "video",
2409 "loglevel",
2410 "hugepages",
2411 "hugepagesz",
2412 "default_hugepagesz",
2413 "nvme_core.default_ps_max_latency_us",
2414 "usbcore.autosuspend",
2415 "nvidia_drm.modeset",
2416 "nvidia_drm.fbdev",
2417 "i915.enable_psr",
2418 "amdgpu.dc",
2419 "acpi_backlight",
2420 "mem_sleep_default",
2421 "pcie_aspm",
2422 "transparent_hugepage",
2423 "zswap.enabled",
2424 "systemd.show_status",
2425 "plymouth.enable",
2426];
2427
2428const KARG_LIST_VALUES: &[(&str, &[&str])] = &[(
2434 "rd.driver.blacklist",
2435 &[
2436 "nouveau",
2437 "nvidia",
2438 "nvidia_drm",
2439 "nvidia_modeset",
2440 "radeon",
2441 "amdgpu",
2442 "i915",
2443 ],
2444)];
2445
2446const KARG_HARDENING_VALUES: &[(&str, &[&str])] = &[
2451 ("lockdown", &["integrity", "confidentiality"]),
2452 ("module.sig_enforce", &["1"]),
2453 ("intel_iommu", &["on"]),
2454 ("amd_iommu", &["force_isolation"]),
2455 ("init_on_alloc", &["1"]),
2456 ("init_on_free", &["1"]),
2457 ("page_alloc.shuffle", &["1"]),
2458 ("randomize_kstack_offset", &["on"]),
2459 ("mitigations", &["auto", "auto,nosmt"]),
2460 ("pti", &["on"]),
2461 ("vsyscall", &["none"]),
2462 ("debugfs", &["off"]),
2463];
2464
2465const KARG_WEAKENING: &[&str] = &[
2468 "selinux=0",
2469 "enforcing=0",
2470 "apparmor=0",
2471 "lockdown=none",
2472 "debugfs=on",
2473 "iommu=off",
2474 "intel_iommu=off",
2475 "nokaslr",
2476 "nopti",
2477 "nosmap",
2478 "nosmep",
2479 "nospectre_v1",
2480 "nospectre_v2",
2481 "nospec_store_bypass_disable",
2482 "nospectre_bhb",
2483 "single",
2484 "emergency",
2485 "rescue",
2486 "rd.break",
2487 "rd.shell",
2488 "systemd.debug_shell",
2489 "init=/bin/sh",
2490 "init=/bin/bash",
2491 "mitigations=off",
2492 "pti=off",
2493 "audit=0",
2494 "module.sig_enforce=0",
2495 "init_on_alloc=0",
2496 "init_on_free=0",
2497 "page_alloc.shuffle=0",
2498 "randomize_kstack_offset=off",
2499 "spectre_v2=off",
2500 "spec_store_bypass_disable=off",
2501 "mds=off",
2502 "tsx_async_abort=off",
2503 "mmio_stale_data=off",
2504 "retbleed=off",
2505 "srbds=off",
2506 "l1tf=off",
2507 "spectre_bhi=off",
2508 "gather_data_sampling=off",
2509 "reg_file_data_sampling=off",
2510];
2511
2512fn normalized_kernel_arg(arg: &str) -> String {
2518 let lower = arg.to_lowercase();
2519 match lower.split_once('=') {
2520 Some((key, value)) => format!("{}={value}", key.replace('-', "_")),
2521 None => lower.replace('-', "_"),
2522 }
2523}
2524
2525fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2532 validated_kernel_arg_token(arg, param)?;
2533 let normal = normalized_kernel_arg(arg);
2534 let allowed = match normal.split_once('=') {
2535 None => {
2536 KARG_FLAGS.contains(&normal.as_str()) || KARG_HARDENING_FLAGS.contains(&normal.as_str())
2537 }
2538 Some((key, value)) => {
2539 (!value.is_empty() && KARG_VALUE_KEYS.contains(&key))
2540 || KARG_LIST_VALUES
2541 .iter()
2542 .any(|(k, allowed)| *k == key && value.split(',').all(|v| allowed.contains(&v)))
2543 || KARG_HARDENING_VALUES
2544 .iter()
2545 .any(|(k, values)| *k == key && values.contains(&value))
2546 }
2547 };
2548 if allowed {
2549 Ok(())
2550 } else {
2551 Err(ExecutorError::InvalidParam(param))
2552 }
2553}
2554
2555fn validated_kernel_arg_token(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2564 if arg.is_empty() || arg.len() > crate::actions::validate::SAFE_ARG_MAX_BYTES {
2565 return Err(ExecutorError::InvalidParam(param));
2566 }
2567 if arg.starts_with('-') {
2568 return Err(ExecutorError::InvalidParam(param));
2569 }
2570 if !arg.chars().all(|c| {
2571 c.is_ascii_alphanumeric()
2572 || matches!(c, '.' | '_' | ':' | '/' | '+' | '@' | '-' | '=' | ',')
2573 }) {
2574 return Err(ExecutorError::InvalidParam(param));
2575 }
2576 Ok(())
2577}
2578
2579fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2587 validated_kernel_arg_token(arg, param)?;
2588 let normal = normalized_kernel_arg(arg);
2589 let key = normal.split('=').next().unwrap_or(&normal);
2590 if KARG_WEAKENING.contains(&normal.as_str())
2591 || KARG_FLAGS.contains(&key)
2592 || KARG_VALUE_KEYS.contains(&key)
2593 || KARG_LIST_VALUES.iter().any(|(k, _)| *k == key)
2594 {
2595 Ok(())
2596 } else {
2597 Err(ExecutorError::InvalidParam(param))
2598 }
2599}
2600
2601fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2609 let normalized = s.trim().to_ascii_uppercase();
2610 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2611 "TERM" => Ok("TERM"),
2612 "KILL" => Ok("KILL"),
2613 "HUP" => Ok("HUP"),
2614 "INT" => Ok("INT"),
2615 _ => Err(ExecutorError::InvalidParam("signal")),
2616 }
2617}
2618
2619pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2629 match action_name {
2630 "UpdateSystem"
2631 | "InstallPackages"
2632 | "RemovePackages"
2633 | "RebaseSystem"
2634 | "SetKernelArguments"
2635 | "AddLayeredPackage"
2636 | "RemoveLayeredPackage"
2637 | "ReplaceLayeredPackage"
2638 | "ResetLayeredPackageOverride"
2639 | "RemoveBasePackage" => Some(ActionSpec {
2640 action_name: "RollbackDeployment",
2641 mechanism: ActionMechanism::Command {
2642 program: "rpm-ostree",
2643 args: vec!["rollback".to_string()],
2644 },
2645 risk_level: sysknife_types::RiskLevel::High,
2646 reboot_required: true,
2647 rollback_available: false,
2648 }),
2649 _ => None,
2650 }
2651}
2652
2653#[cfg(test)]
2654mod tests {
2655 use super::*;
2656 use serde_json::json;
2657 use sysknife_types::RiskLevel;
2658 use tempfile::tempdir;
2659
2660 #[test]
2663 fn build_spec_rejects_unknown_keys_across_catalogue() {
2664 for name in sysknife_types::KNOWN_ACTION_NAMES {
2665 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2666 continue;
2667 }
2668 assert!(
2669 matches!(
2670 build_action_spec(name, &json!({"__unknown_param": 1})),
2671 Err(ExecutorError::InvalidParam(_))
2672 ),
2673 "{name} must reject unknown keys before reading required params"
2674 );
2675 }
2676 }
2677
2678 #[test]
2679 fn build_spec_rejects_non_object_params_across_catalogue() {
2680 for name in sysknife_types::KNOWN_ACTION_NAMES {
2681 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2682 continue;
2683 }
2684 for params in [json!(null), json!([]), json!(42), json!(true), json!("{}")] {
2685 assert!(
2686 matches!(
2687 build_action_spec(name, ¶ms),
2688 Err(ExecutorError::InvalidParam(_))
2689 ),
2690 "{name} must reject non-object params: {params}"
2691 );
2692 }
2693 }
2694 }
2695
2696 #[test]
2697 fn build_spec_rejects_cross_distro_kernel_argument_key() {
2698 assert!(matches!(
2699 build_action_spec("SetKernelArguments", &json!({"append": ["quiet"]})),
2700 Err(ExecutorError::InvalidParam(_))
2701 ));
2702 assert!(matches!(
2703 build_action_spec("UfwEnable", &json!({"x": 1})),
2704 Err(ExecutorError::InvalidParam(_))
2705 ));
2706 }
2707
2708 #[test]
2709 fn build_spec_accepts_catalogue_params_and_username_aliases() {
2710 for name in sysknife_types::KNOWN_ACTION_NAMES {
2711 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2712 continue;
2713 }
2714 let params = match *name {
2717 "PinDeployment" | "UnpinDeployment" => json!({"index": 0}),
2718 "RebaseSystem" => json!({"target_ref": "fedora-stable"}),
2719 "SetKernelArguments" => json!({"add": ["quiet"], "remove": ["splash"]}),
2720 "ListFlatpakRemotes"
2721 | "ListInstalledFlatpaks"
2722 | "ListContainers"
2723 | "ListToolboxes"
2724 | "DeleteUser"
2725 | "LockUserAccount"
2726 | "UnlockUserAccount"
2727 | "GetAuthorizedKeys"
2728 | "UbuntuListFlatpaks" => json!({"username": "alice"}),
2729 "InstallFlatpak" | "UbuntuInstallFlatpak" => {
2730 json!({"username": "alice", "app_id": "org.mozilla.firefox", "remote": "flathub"})
2731 }
2732 "RemoveFlatpak"
2733 | "GetFlatpakAppInfo"
2734 | "UbuntuRemoveFlatpak"
2735 | "UpdateFlatpak"
2736 | "UbuntuUpdateFlatpak" => {
2737 json!({"username": "alice", "app_id": "org.mozilla.firefox"})
2738 }
2739 "SearchFlatpakApps" | "AptSearch" => json!({"term": "vim"}),
2740 "AddFlatpakRemote" => {
2741 json!({"username": "alice", "remote": "flathub", "url": "https://flathub.org/repo/flathub.flatpakrepo"})
2742 }
2743 "RemoveFlatpakRemote" => json!({"username": "alice", "remote": "flathub"}),
2744 "CreateContainer" => {
2745 json!({"username": "alice", "name": "test", "image": "alpine"})
2746 }
2747 "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
2748 | "RemoveToolbox" => json!({"username": "alice", "name": "test"}),
2749 "InstallPackages" | "RemovePackages" => json!({"packages": ["vim"]}),
2750 "AddLayeredPackage"
2751 | "RemoveLayeredPackage"
2752 | "RemoveBasePackage"
2753 | "AptInstall"
2754 | "AptRemove"
2755 | "AptPurge"
2756 | "AptHold"
2757 | "AptUnhold"
2758 | "AptShow"
2759 | "GetAptPins" => json!({"package": "vim"}),
2760 "ReplaceLayeredPackage" => json!({"old": "vim", "new": "nano"}),
2761 "AddPackageRepository" => {
2762 json!({"repo_id": "test", "repo_url": "https://example.com/repo"})
2763 }
2764 "RemovePackageRepository"
2765 | "EnablePackageRepository"
2766 | "DisablePackageRepository" => json!({"repo_id": "test"}),
2767 "StartService"
2768 | "StopService"
2769 | "RestartService"
2770 | "MaskService"
2771 | "UnmaskService"
2772 | "GetServiceLogs"
2773 | "GetServiceStatus"
2774 | "ReloadService"
2775 | "GetServiceResourceLimits" => json!({"unit": "nginx.service"}),
2776 "SetServiceEnabled" => json!({"unit": "nginx.service", "enabled": true}),
2777 "CreateScheduledJob" => {
2778 json!({"name": "test", "command": "/usr/bin/true", "schedule": "daily"})
2779 }
2780 "SetServiceResourceLimits" => {
2781 json!({"unit": "nginx.service", "memory_max": "1G", "memory_high": "512M", "cpu_quota": "50%", "tasks_max": "100"})
2782 }
2783 "CreateToolbox" => {
2784 json!({"username": "alice", "name": "test", "image": "fedora-toolbox", "release": "41"})
2785 }
2786 "SetHostname" => json!({"hostname": "testhost"}),
2787 "SetTimezone" => json!({"timezone": "Europe/London"}),
2788 "SetLocale" => json!({"locale": "en_US.UTF-8"}),
2789 "SetNtp" | "ConfigureUnattendedUpgrades" => json!({"enabled": true}),
2790 "SignalProcess" => json!({"pid": 42, "signal": "TERM"}),
2791 "GetJournalLog" => {
2792 json!({"unit": "nginx.service", "priority": "warning", "since": "yesterday", "until": "now", "grep": "error", "lines": 10, "boot": true, "kernel": false})
2793 }
2794 "VacuumJournal" => json!({"size_mb": 10}),
2795 "ExtendLogicalVolume" => json!({"vg": "vg0", "lv": "data", "size": "+1G"}),
2796 "CreateLogicalVolume" => json!({"vg": "vg0", "name": "data", "size": "1G"}),
2797 "CreateLvSnapshot" => {
2798 json!({"vg": "vg0", "origin": "data", "snapshot": "backup", "size": "1G"})
2799 }
2800 "GetSysctl" => json!({"key": "net.ipv4.ip_forward"}),
2801 "SetSysctl" => json!({"key": "net.ipv4.ip_forward", "value": "1"}),
2802 "AddMount" => {
2803 json!({"device": "/dev/sdb1", "mountpoint": "/mnt/data", "fstype": "ext4", "options": "defaults"})
2804 }
2805 "RemoveMount" => json!({"mountpoint": "/mnt/data"}),
2806 "AddSwap" => json!({"file": "/swapfile", "size_mb": 1024}),
2807 "RemoveSwap" => json!({"file": "/swapfile"}),
2808 "GrantSudoAccess" => {
2809 json!({"name": "test", "user": "alice", "commands": "/usr/bin/uptime", "runas": "root", "nopasswd": true})
2810 }
2811 "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" => {
2812 json!({"name": "test"})
2813 }
2814 "SetAptPin" => {
2815 json!({"name": "test", "package": "vim", "pin": "version 1.*", "priority": 500})
2816 }
2817 "GetLogrotateStatus" => json!({"config": "/var/log/app.log"}),
2818 "ConfigureLogRotation" => {
2819 json!({"name": "test", "path": "/var/log/app.log", "frequency": "daily", "rotate": 7, "compress": true})
2820 }
2821 "ConfigureRemoteSyslog" => {
2822 json!({"host": "logs.example.com", "port": 514, "protocol": "tcp"})
2823 }
2824 "GetPasswordAging" => json!({"user": "alice"}),
2825 "SetPasswordAging" => {
2826 json!({"user": "alice", "max_days": 90, "min_days": 1, "warn_days": 7})
2827 }
2828 "SetPasswordPolicy" => {
2829 json!({"minlen": 12, "dcredit": -1, "ucredit": -1, "lcredit": -1, "ocredit": -1})
2830 }
2831 "SetAccountLockout" => json!({"deny": 5, "unlock_time": 600, "fail_interval": 900}),
2832 "ConfigureWifi" => json!({"ssid": "testwifi", "password": "examplepassword"}),
2833 "SetDnsServers" | "ResolvectlSetDns" => {
2834 json!({"interface": "eth0", "servers": ["1.1.1.1"]})
2835 }
2836 "ConfigureFirewall" => json!({"zone": "public", "service": "ssh", "enabled": true}),
2837 "CreateUser" => {
2838 json!({"username": "alice", "shell": "/bin/bash", "home": "/home/alice"})
2839 }
2840 "AddUserToGroup" | "RemoveUserFromGroup" => {
2841 json!({"username": "alice", "group": "developers"})
2842 }
2843 "CreateGroup" => json!({"group": "developers", "system": true}),
2844 "DeleteGroup" => json!({"group": "developers"}),
2845 "AddAuthorizedKey" | "RemoveAuthorizedKey" => {
2846 json!({"username": "alice", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey alice"})
2847 }
2848 "SetSshdOption" => json!({"option": "PasswordAuthentication", "value": "no"}),
2849 "AddPpa" | "RemovePpa" => json!({"name": "deadsnakes/ppa"}),
2850 "SnapInstall" => json!({"name": "test", "channel": "stable", "auto_update": true}),
2851 "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo"
2852 | "SnapRevert" | "SnapClassicInstall" | "DistroboxRemove" => {
2853 json!({"name": "test"})
2854 }
2855 "GrubSetKargs" => json!({"append": ["quiet"], "delete": ["splash"]}),
2856 "UfwAllow" | "UfwDeny" => json!({"port_or_service": "22/tcp"}),
2857 "UfwStatus" => json!({"numbered": true}),
2858 "DistroboxCreate" => json!({"name": "test", "image": "ubuntu"}),
2859 "NetplanSet" => json!({"key": "ethernets.eth0.dhcp4", "value": "true"}),
2860 "UfwDeleteRule" => json!({"rule_number": 1}),
2861 "UfwLimit" => json!({"target": "ssh"}),
2862 "ProAttach" => json!({"token": "exampletoken"}),
2863 "EnableProService" | "DisableProService" => json!({"service": "esm-apps"}),
2864 "AppArmorEnforce" | "AppArmorComplain" => {
2865 json!({"profile_path": "/etc/apparmor.d/usr.bin.test"})
2866 }
2867 "Fail2banStatus" => json!({"jail": "sshd"}),
2868 "Fail2banBanIp" | "Fail2banUnbanIp" => json!({"jail": "sshd", "ip": "192.0.2.1"}),
2869 "ConfigureFail2banJail" => {
2870 json!({"name": "sshd", "enabled": true, "maxretry": 5, "bantime": 600, "findtime": 900})
2871 }
2872 "AddAuditRule" => json!({"path": "/etc/passwd", "perms": "wa", "key": "identity"}),
2873 "RemoveAuditRule" => json!({"key": "identity"}),
2874 "ObtainCertificate" => {
2875 json!({"domains": ["example.com"], "domain": "example.com", "email": "admin@example.com", "challenge": "standalone"})
2876 }
2877 _ => json!({}),
2878 };
2879 build_action_spec(name, ¶ms)
2880 .unwrap_or_else(|err| panic!("valid parameters rejected for {name}: {err}"));
2881 if params.get("username").is_some() {
2882 let mut aliased = params;
2883 let username = aliased.as_object_mut().unwrap().remove("username").unwrap();
2884 aliased["user"] = username;
2885 build_action_spec(name, &aliased)
2886 .unwrap_or_else(|err| panic!("user alias rejected for {name}: {err}"));
2887 }
2888 }
2889 for params in [json!({"retain_days": 7}), json!({"size_mb": 10})] {
2890 build_action_spec("VacuumJournal", ¶ms).unwrap();
2891 }
2892 build_action_spec(
2893 "ObtainCertificate",
2894 &json!({"domain": "example.com", "email": "admin@example.com"}),
2895 )
2896 .unwrap();
2897 build_action_spec("SignalProcess", &json!({"pid": "42"})).unwrap();
2898 build_action_spec("SetKernelArguments", &json!({"add": null, "remove": null})).unwrap();
2899 }
2900
2901 #[test]
2902 fn build_spec_no_params_for_get_system_state() {
2903 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2904 assert_eq!(spec.action_name, "GetSystemState");
2905 assert_eq!(spec.risk_level, RiskLevel::Low);
2906 }
2907
2908 #[test]
2909 fn build_spec_get_datetime_is_low_risk() {
2910 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2911 assert_eq!(spec.action_name, "GetDateTime");
2912 assert_eq!(spec.risk_level, RiskLevel::Low);
2913 assert!(!spec.reboot_required);
2914 }
2915
2916 #[test]
2917 fn build_spec_unknown_action_returns_error() {
2918 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2919 assert!(
2920 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2921 "expected UnknownAction, got: {err}"
2922 );
2923 }
2924
2925 #[test]
2926 fn build_spec_missing_param_for_install_flatpak() {
2927 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2929 assert!(
2930 matches!(err, ExecutorError::MissingParam("username")),
2931 "expected MissingParam(username), got: {err}"
2932 );
2933 }
2934
2935 #[test]
2939 fn build_spec_flatpak_accepts_user_alias() {
2940 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2941 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2942 }
2943
2944 #[test]
2946 fn build_spec_resolve_username_prefers_explicit_username() {
2947 let spec = build_action_spec(
2948 "ListInstalledFlatpaks",
2949 &json!({ "username": "alice", "user": "bob" }),
2950 )
2951 .unwrap();
2952 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2954 }
2955
2956 #[test]
2959 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2960 let spec = build_action_spec(
2961 "InstallFlatpak",
2962 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2963 )
2964 .unwrap();
2965 assert_eq!(
2966 spec.mechanism,
2967 ActionMechanism::Command {
2968 program: "sudo",
2969 args: vec![
2970 "/usr/lib/sysknife/action-steps".to_string(),
2971 "flatpak".to_string(),
2972 "alice".to_string(),
2973 "install".to_string(),
2974 "--user".to_string(),
2975 "-y".to_string(),
2976 "flathub".to_string(),
2977 "org.mozilla.firefox".to_string(),
2978 ],
2979 }
2980 );
2981 }
2982
2983 #[test]
2984 fn build_spec_install_flatpak_injects_app_and_remote() {
2985 let spec = build_action_spec(
2986 "InstallFlatpak",
2987 &json!({
2988 "username": "alice",
2989 "app_id": "org.mozilla.firefox",
2990 "remote": "flathub"
2991 }),
2992 )
2993 .unwrap();
2994 assert_eq!(spec.action_name, "InstallFlatpak");
2995 assert_eq!(
2996 spec.mechanism,
2997 ActionMechanism::Command {
2998 program: "sudo",
2999 args: vec![
3000 "/usr/lib/sysknife/action-steps".to_string(),
3001 "flatpak".to_string(),
3002 "alice".to_string(),
3003 "install".to_string(),
3004 "--user".to_string(),
3005 "-y".to_string(),
3006 "flathub".to_string(),
3007 "org.mozilla.firefox".to_string(),
3008 ],
3009 }
3010 );
3011 }
3012
3013 #[test]
3014 fn build_spec_pin_deployment_injects_index() {
3015 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
3016 assert_eq!(
3017 spec.mechanism,
3018 ActionMechanism::Command {
3019 program: "sudo",
3020 args: vec![
3021 "ostree".to_string(),
3022 "admin".to_string(),
3023 "pin".to_string(),
3024 "1".to_string()
3025 ],
3026 }
3027 );
3028 }
3029
3030 #[test]
3031 fn build_spec_unpin_deployment_includes_unpin_flag() {
3032 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
3033 assert_eq!(
3034 spec.mechanism,
3035 ActionMechanism::Command {
3036 program: "sudo",
3037 args: vec![
3038 "ostree".to_string(),
3039 "admin".to_string(),
3040 "pin".to_string(),
3041 "--unpin".to_string(),
3042 "2".to_string(),
3043 ],
3044 }
3045 );
3046 }
3047
3048 #[test]
3049 fn require_u32_rejects_overflow() {
3050 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
3051 assert!(
3052 matches!(err, ExecutorError::InvalidParam("index")),
3053 "expected InvalidParam(index), got: {err}"
3054 );
3055 }
3056
3057 #[test]
3062 fn rpm_ostree_parity_corpus_agrees_with_the_daemon_validators() {
3063 let corpus = include_str!("../tests/data/rpm-ostree-parity.txt");
3064 let mut seen: std::collections::BTreeSet<(String, String)> = Default::default();
3065 let mut checked = 0;
3066 for (index, line) in corpus.lines().enumerate() {
3067 let number = index + 1;
3068 if line.is_empty() || line.starts_with('#') {
3069 continue;
3070 }
3071 let mut parts = line.splitn(3, ' ');
3072 let (Some(kind), Some(verdict), Some(operand)) =
3073 (parts.next(), parts.next(), parts.next())
3074 else {
3075 panic!("corpus line {number} is not `<kind> <ok|refuse> <operand>`: {line:?}");
3076 };
3077 let accepted = match kind {
3078 "package" => validated_install_package(operand, "package").is_ok(),
3079 "ref" => validated_rebase_ref(operand, "target_ref").is_ok(),
3080 "append" => validated_safe_kernel_arg(operand, "add").is_ok(),
3081 "delete" => validated_safe_kernel_arg_removal(operand, "remove").is_ok(),
3082 other => panic!("corpus line {number}: unknown kind {other:?}"),
3083 };
3084 match verdict {
3085 "ok" => assert!(
3086 accepted,
3087 "corpus line {number}: `{kind} {operand}` must be accepted"
3088 ),
3089 "refuse" => assert!(
3090 !accepted,
3091 "corpus line {number}: `{kind} {operand}` must be refused"
3092 ),
3093 other => panic!("corpus line {number}: unknown verdict {other:?}"),
3094 }
3095 seen.insert((kind.to_string(), verdict.to_string()));
3096 checked += 1;
3097 }
3098 assert!(
3099 checked >= 60,
3100 "only {checked} corpus lines were checked; the corpus was truncated or not parsed"
3101 );
3102 for kind in ["package", "ref", "append", "delete"] {
3103 for verdict in ["ok", "refuse"] {
3104 assert!(
3105 seen.contains(&(kind.to_string(), verdict.to_string())),
3106 "the corpus has no `{kind} {verdict}` line, so that half is unchecked"
3107 );
3108 }
3109 }
3110 }
3111
3112 #[test]
3113 fn rebase_system_refuses_a_local_source() {
3114 for target_ref in ["/tmp/repo", "ostree-unverified-image:oci:/tmp/img"] {
3115 let err = build_action_spec("RebaseSystem", &json!({ "target_ref": target_ref }))
3116 .unwrap_err();
3117 assert!(
3118 matches!(err, ExecutorError::InvalidParam("target_ref")),
3119 "{target_ref:?}: expected InvalidParam(target_ref), got: {err}"
3120 );
3121 }
3122 }
3123
3124 #[test]
3125 fn build_spec_rebase_system_injects_target_ref() {
3126 let spec = build_action_spec(
3127 "RebaseSystem",
3128 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
3129 )
3130 .unwrap();
3131 assert_eq!(
3132 spec.mechanism,
3133 ActionMechanism::Command {
3134 program: "sudo",
3135 args: vec![
3136 "/usr/lib/sysknife/action-steps".to_string(),
3137 "rpm-ostree".to_string(),
3138 "rebase".to_string(),
3139 "fedora/41/x86_64/silverblue".to_string(),
3140 ],
3141 }
3142 );
3143 }
3144
3145 #[test]
3146 fn build_spec_set_kernel_arguments_appends_and_deletes() {
3147 let spec = build_action_spec(
3148 "SetKernelArguments",
3149 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
3150 )
3151 .unwrap();
3152 assert_eq!(
3153 spec.mechanism,
3154 ActionMechanism::Command {
3155 program: "sudo",
3156 args: vec![
3157 "/usr/lib/sysknife/action-steps".to_string(),
3158 "rpm-ostree".to_string(),
3159 "kargs".to_string(),
3160 "append".to_string(),
3161 "nomodeset".to_string(),
3162 "delete".to_string(),
3163 "quiet".to_string(),
3164 ],
3165 }
3166 );
3167 }
3168
3169 #[test]
3170 fn build_spec_set_kernel_arguments_with_empty_arrays() {
3171 let spec =
3172 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
3173 assert_eq!(
3174 spec.mechanism,
3175 ActionMechanism::Command {
3176 program: "sudo",
3177 args: vec![
3178 "/usr/lib/sysknife/action-steps".to_string(),
3179 "rpm-ostree".to_string(),
3180 "kargs".to_string(),
3181 ],
3182 }
3183 );
3184 }
3185
3186 #[test]
3187 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
3188 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
3189 assert_eq!(
3190 spec.mechanism,
3191 ActionMechanism::Command {
3192 program: "sudo",
3193 args: vec![
3194 "/usr/lib/sysknife/action-steps".to_string(),
3195 "rpm-ostree".to_string(),
3196 "kargs".to_string(),
3197 ],
3198 }
3199 );
3200 }
3201
3202 #[test]
3205 fn delete_user_rejects_critical_account() {
3206 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
3207 assert!(
3208 matches!(err, ExecutorError::InvalidParam("username")),
3209 "expected InvalidParam(username), got: {err}"
3210 );
3211 }
3212
3213 #[test]
3214 fn delete_group_rejects_critical_group() {
3215 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
3216 assert!(
3217 matches!(err, ExecutorError::InvalidParam("group")),
3218 "expected InvalidParam(group), got: {err}"
3219 );
3220 }
3221
3222 #[test]
3223 fn lock_user_account_rejects_critical_account() {
3224 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
3225 assert!(
3226 matches!(err, ExecutorError::InvalidParam("username")),
3227 "expected InvalidParam(username), got: {err}"
3228 );
3229 }
3230
3231 #[test]
3235 fn unlock_user_account_rejects_critical_account() {
3236 let err =
3237 build_action_spec("UnlockUserAccount", &json!({ "username": "root" })).unwrap_err();
3238 assert!(
3239 matches!(err, ExecutorError::InvalidParam("username")),
3240 "expected InvalidParam(username), got: {err}"
3241 );
3242 assert!(build_action_spec("UnlockUserAccount", &json!({ "username": "alice" })).is_ok());
3243 }
3244
3245 #[test]
3246 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
3247 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
3248 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
3249 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
3250 }
3251
3252 #[test]
3255 fn build_spec_add_package_repository_rejects_path_traversal() {
3256 let err = build_action_spec(
3257 "AddPackageRepository",
3258 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
3259 )
3260 .unwrap_err();
3261 assert!(
3262 matches!(err, ExecutorError::InvalidParam("repo_id")),
3263 "expected InvalidParam(repo_id), got: {err}"
3264 );
3265 }
3266
3267 #[test]
3268 fn build_spec_add_package_repository_rejects_newline_in_url() {
3269 let err = build_action_spec(
3270 "AddPackageRepository",
3271 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
3272 )
3273 .unwrap_err();
3274 assert!(
3275 matches!(err, ExecutorError::InvalidParam("repo_url")),
3276 "expected InvalidParam(repo_url), got: {err}"
3277 );
3278 }
3279
3280 #[test]
3281 fn build_spec_add_package_repository_accepts_valid_repo_id() {
3282 let spec = build_action_spec(
3283 "AddPackageRepository",
3284 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
3285 )
3286 .unwrap();
3287 assert_eq!(spec.action_name, "AddPackageRepository");
3288 }
3289
3290 #[test]
3291 fn build_spec_remove_package_repository_rejects_path_traversal() {
3292 let err = build_action_spec(
3293 "RemovePackageRepository",
3294 &json!({ "repo_id": "../../etc/passwd" }),
3295 )
3296 .unwrap_err();
3297 assert!(
3298 matches!(err, ExecutorError::InvalidParam("repo_id")),
3299 "expected InvalidParam(repo_id), got: {err}"
3300 );
3301 }
3302
3303 #[tokio::test]
3304 async fn execute_spec_command_captures_stdout() {
3305 let spec = ActionSpec {
3306 action_name: "GetSystemState",
3307 mechanism: ActionMechanism::Command {
3308 program: "echo",
3309 args: vec!["hello".to_string()],
3310 },
3311 risk_level: RiskLevel::Low,
3312 reboot_required: false,
3313 rollback_available: false,
3314 };
3315 let out = execute_spec(&spec).await.unwrap();
3316 assert_eq!(out.stdout.trim(), "hello");
3317 assert_eq!(out.exit_code, 0);
3318 }
3319
3320 #[test]
3328 fn action_not_stopped_names_the_group_and_the_timeout() {
3329 let err = ExecutorError::ActionNotStopped {
3330 program: "rpm-ostree".to_string(),
3331 pgid: 4242,
3332 timeout_secs: 7200,
3333 };
3334 let msg = err.to_string();
3335 assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
3336 assert!(msg.contains("7200"), "must name the timeout: {msg}");
3337 assert!(
3338 msg.contains("could not be confirmed stopped"),
3339 "must say what happened: {msg}"
3340 );
3341 assert!(
3342 msg.contains("rollback was skipped"),
3343 "must say the rollback was skipped: {msg}"
3344 );
3345 }
3346
3347 #[tokio::test]
3352 async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
3353 let mut child = tokio::process::Command::new("sh")
3354 .args(["-c", "sleep 3600 & wait"])
3355 .process_group(0)
3356 .stdin(Stdio::null())
3357 .stdout(Stdio::null())
3358 .stderr(Stdio::null())
3359 .spawn()
3360 .expect("spawn");
3361 SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
3362 let started = tokio::time::Instant::now();
3363 let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
3364 let elapsed = started.elapsed();
3365
3366 assert!(
3367 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
3368 "a killable group must report a plain stopped-timeout, got: {err:?}"
3369 );
3370 assert!(
3371 elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
3372 "must return promptly, took {elapsed:?}"
3373 );
3374 assert_eq!(
3378 SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
3379 0,
3380 "a killable group must not escalate to the privileged reaper"
3381 );
3382 }
3383
3384 #[tokio::test]
3392 async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
3393 let dir = tempfile::tempdir().expect("tempdir");
3394 let pgid_file = dir.path().join("pgid");
3395 let script = format!(
3399 "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
3400 pgid_file.display()
3401 );
3402 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3403 let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
3404 .await
3405 .expect_err("non-UTF-8 stdout must fail the action");
3406 assert!(
3407 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
3408 "the read failure itself must be surfaced, got: {err:?}"
3409 );
3410
3411 let pgid: i32 = std::fs::read_to_string(&pgid_file)
3412 .expect("the shell must have recorded its pgid")
3413 .trim()
3414 .parse()
3415 .expect("pgid");
3416 assert!(
3417 probe_group(pgid).is_gone(),
3418 "process group {pgid} survived the read failure; \
3419 the privileged child was orphaned"
3420 );
3421 }
3422
3423 #[test]
3424 fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
3425 assert_eq!(
3427 reap_command(
3428 99,
3429 "sudo",
3430 &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
3431 ),
3432 vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
3433 );
3434 assert_eq!(
3436 reap_command(
3437 99,
3438 "sudo",
3439 &[
3440 "env".to_string(),
3441 "apt-get".to_string(),
3442 "install".to_string()
3443 ]
3444 ),
3445 root_group_kill_argv("TERM", 99)
3446 );
3447 }
3448
3449 #[test]
3454 fn reap_command_still_cancels_an_rpm_ostree_action_behind_the_helper() {
3455 for name in [
3456 "AddLayeredPackage",
3457 "InstallPackages",
3458 "RemovePackages",
3459 "RemoveLayeredPackage",
3460 "ReplaceLayeredPackage",
3461 "RemoveBasePackage",
3462 "RebaseSystem",
3463 "SetKernelArguments",
3464 ] {
3465 let params = match name {
3466 "InstallPackages" | "RemovePackages" => json!({"packages": ["vim"]}),
3467 "ReplaceLayeredPackage" => json!({"old": "vim", "new": "nano"}),
3468 "RebaseSystem" => json!({"target_ref": "fedora:fedora/41/x86_64/silverblue"}),
3469 "SetKernelArguments" => json!({"add": ["quiet"]}),
3470 _ => json!({"package": "vim"}),
3471 };
3472 let spec = build_action_spec(name, ¶ms).unwrap();
3473 let ActionMechanism::Command { program, args } = &spec.mechanism else {
3474 panic!("{name} is not a command");
3475 };
3476 assert_eq!(
3477 args[0], "/usr/lib/sysknife/action-steps",
3478 "{name}: {args:?}"
3479 );
3480 assert_eq!(
3481 reap_command(99, program, args),
3482 vec!["-n", "/usr/bin/rpm-ostree", "cancel"],
3483 "{name} would be group-killed while its transaction runs on"
3484 );
3485 }
3486 }
3487
3488 #[test]
3489 fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
3490 assert_eq!(
3494 root_group_kill_argv("KILL", 4242),
3495 vec![
3496 "-n",
3497 "/usr/lib/sysknife/action-steps",
3498 "signal",
3499 "KILL",
3500 "-4242"
3501 ]
3502 );
3503 assert_eq!(
3504 root_group_kill_argv("TERM", 7)[3..].to_vec(),
3505 vec!["TERM", "-7"]
3506 );
3507 }
3508
3509 #[test]
3510 fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
3511 let sudo = |a: &[&str]| {
3512 is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
3513 };
3514 assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
3516 assert!(sudo(&[
3517 "rpm-ostree",
3518 "rebase",
3519 "fedora:fedora/40/x86_64/silverblue"
3520 ]));
3521 assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
3522 assert!(!sudo(&[
3525 "env",
3526 "DEBIAN_FRONTEND=noninteractive",
3527 "apt-get",
3528 "install",
3529 "vim"
3530 ]));
3531 assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
3532 assert!(!is_rpm_ostree_action("sudo", &[]));
3533 assert!(is_rpm_ostree_action(
3535 "/usr/bin/rpm-ostree",
3536 &["deploy".to_string()]
3537 ));
3538 assert!(!is_rpm_ostree_action(
3541 "sudo",
3542 &["explain-rpm-ostree-usage".to_string()]
3543 ));
3544 }
3545
3546 #[tokio::test]
3549 async fn real_executor_streams_each_stdout_line() {
3550 let spec = ActionSpec {
3551 action_name: "GetSystemState",
3552 mechanism: ActionMechanism::Command {
3553 program: "printf",
3554 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
3555 },
3556 risk_level: RiskLevel::Low,
3557 reboot_required: false,
3558 rollback_available: false,
3559 };
3560 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3561 let out = RealActionExecutor
3562 .execute_with_progress(&spec, tx)
3563 .await
3564 .unwrap();
3565
3566 let mut lines = Vec::new();
3567 while let Ok(line) = rx.try_recv() {
3568 lines.push(line);
3569 }
3570 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
3571 assert_eq!(out.exit_code, 0);
3572 assert!(out.stdout.contains("line-two"));
3573 }
3574
3575 #[tokio::test]
3576 async fn execute_spec_file_write_creates_file() {
3577 let dir = tempdir().unwrap();
3578 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
3579 let spec = ActionSpec {
3580 action_name: "AddPackageRepository",
3581 mechanism: ActionMechanism::FileWrite {
3582 path: path.clone(),
3583 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
3584 },
3585 risk_level: RiskLevel::Medium,
3586 reboot_required: false,
3587 rollback_available: true,
3588 };
3589 let out = execute_spec(&spec).await.unwrap();
3590 assert_eq!(out.exit_code, 0);
3591 assert_eq!(
3592 std::fs::read_to_string(&path).unwrap(),
3593 "[repo]\nbaseurl=https://example.test\n"
3594 );
3595 }
3596
3597 #[tokio::test]
3598 async fn execute_spec_file_patch_replaces_first_occurrence() {
3599 let dir = tempdir().unwrap();
3600 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3601 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
3602 let spec = ActionSpec {
3603 action_name: "EnablePackageRepository",
3604 mechanism: ActionMechanism::FilePatch {
3605 path: path.clone(),
3606 search: "enabled=0".to_string(),
3607 replace: "enabled=1".to_string(),
3608 },
3609 risk_level: RiskLevel::Medium,
3610 reboot_required: false,
3611 rollback_available: true,
3612 };
3613 execute_spec(&spec).await.unwrap();
3614 assert_eq!(
3615 std::fs::read_to_string(&path).unwrap(),
3616 "[myrepo]\nenabled=1\n"
3617 );
3618 }
3619
3620 #[tokio::test]
3621 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
3622 let dir = tempdir().unwrap();
3623 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3624 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
3625 let spec = ActionSpec {
3626 action_name: "EnablePackageRepository",
3627 mechanism: ActionMechanism::FilePatch {
3628 path: path.clone(),
3629 search: "enabled=0".to_string(),
3630 replace: "enabled=1".to_string(),
3631 },
3632 risk_level: RiskLevel::Medium,
3633 reboot_required: false,
3634 rollback_available: true,
3635 };
3636 let out = execute_spec(&spec).await.unwrap();
3637 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
3638 assert!(
3639 out.stderr.contains("search string not found in file"),
3640 "stderr should explain the failure: {}",
3641 out.stderr
3642 );
3643 assert_eq!(
3645 std::fs::read_to_string(&path).unwrap(),
3646 "[myrepo]\nenabled=1\n"
3647 );
3648 }
3649
3650 #[tokio::test]
3651 async fn execute_spec_file_patch_allows_empty_search_string() {
3652 let dir = tempdir().unwrap();
3655 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
3656 std::fs::write(&path, "hello").unwrap();
3657 let spec = ActionSpec {
3658 action_name: "Test",
3659 mechanism: ActionMechanism::FilePatch {
3660 path: path.clone(),
3661 search: String::new(),
3662 replace: "prefix-".to_string(),
3663 },
3664 risk_level: RiskLevel::Low,
3665 reboot_required: false,
3666 rollback_available: false,
3667 };
3668 let out = execute_spec(&spec).await.unwrap();
3669 assert_eq!(out.exit_code, 0);
3670 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3671 }
3672
3673 #[tokio::test]
3674 async fn execute_spec_file_delete_removes_file() {
3675 let dir = tempdir().unwrap();
3676 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3677 std::fs::write(&path, "[myrepo]\n").unwrap();
3678 let spec = ActionSpec {
3679 action_name: "RemovePackageRepository",
3680 mechanism: ActionMechanism::FileDelete { path: path.clone() },
3681 risk_level: RiskLevel::Medium,
3682 reboot_required: false,
3683 rollback_available: true,
3684 };
3685 execute_spec(&spec).await.unwrap();
3686 assert!(!std::path::Path::new(&path).exists());
3687 }
3688
3689 #[tokio::test]
3690 async fn execute_spec_file_scan_lists_directory_entries() {
3691 let dir = tempdir().unwrap();
3692 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3693 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3694 let spec = ActionSpec {
3695 action_name: "ListPackageRepositories",
3696 mechanism: ActionMechanism::FileScan {
3697 path: dir.path().to_string_lossy().into_owned(),
3698 },
3699 risk_level: RiskLevel::Low,
3700 reboot_required: false,
3701 rollback_available: false,
3702 };
3703 let out = execute_spec(&spec).await.unwrap();
3704 assert!(
3705 out.stdout.contains("a.repo"),
3706 "expected a.repo in: {}",
3707 out.stdout
3708 );
3709 assert!(
3710 out.stdout.contains("b.repo"),
3711 "expected b.repo in: {}",
3712 out.stdout
3713 );
3714 assert_eq!(out.exit_code, 0);
3715 }
3716
3717 #[test]
3720 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3721 let spec = rollback_spec_for("UpdateSystem").unwrap();
3722 assert_eq!(spec.action_name, "RollbackDeployment");
3723 assert!(
3724 matches!(
3725 &spec.mechanism,
3726 ActionMechanism::Command { program: "rpm-ostree", args }
3727 if args == &["rollback".to_string()]
3728 ),
3729 "expected rpm-ostree rollback, got: {:?}",
3730 spec.mechanism
3731 );
3732 assert!(!spec.rollback_available, "rollback spec must not recurse");
3733 }
3734
3735 #[test]
3736 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3737 assert!(rollback_spec_for("InstallPackages").is_some());
3738 }
3739
3740 #[test]
3741 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3742 assert!(rollback_spec_for("RemovePackages").is_some());
3743 }
3744
3745 #[test]
3746 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3747 assert!(rollback_spec_for("RebaseSystem").is_some());
3748 }
3749
3750 #[test]
3751 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3752 assert!(rollback_spec_for("SetKernelArguments").is_some());
3753 }
3754
3755 #[test]
3756 fn rollback_spec_for_read_only_action_returns_none() {
3757 assert!(rollback_spec_for("GetSystemState").is_none());
3758 assert!(rollback_spec_for("ListUsers").is_none());
3759 assert!(rollback_spec_for("GetFirewallState").is_none());
3760 }
3761
3762 #[test]
3763 fn rollback_spec_for_non_rollbackable_actions_return_none() {
3764 assert!(rollback_spec_for("AddUserToGroup").is_none());
3765 assert!(rollback_spec_for("DeleteUser").is_none());
3766 assert!(rollback_spec_for("CleanupDeployments").is_none());
3767 assert!(rollback_spec_for("RollbackDeployment").is_none());
3769 }
3770
3771 #[test]
3774 fn public_key_accepts_valid_ed25519() {
3775 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3776 assert!(validated_public_key(key).is_ok());
3777 }
3778
3779 #[test]
3780 fn public_key_accepts_valid_rsa() {
3781 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3782 assert!(validated_public_key(key).is_ok());
3783 }
3784
3785 #[test]
3786 fn public_key_rejects_empty() {
3787 assert!(matches!(
3788 validated_public_key(""),
3789 Err(ExecutorError::InvalidParam("public_key"))
3790 ));
3791 }
3792
3793 #[test]
3794 fn public_key_rejects_unknown_prefix() {
3795 assert!(matches!(
3796 validated_public_key("sk-rsa AAAA... user@host"),
3797 Err(ExecutorError::InvalidParam("public_key"))
3798 ));
3799 assert!(matches!(
3800 validated_public_key("AAAAB3Nz... user@host"),
3801 Err(ExecutorError::InvalidParam("public_key"))
3802 ));
3803 }
3804
3805 #[test]
3806 fn public_key_rejects_single_quote() {
3807 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3808 assert!(matches!(
3809 validated_public_key(key),
3810 Err(ExecutorError::InvalidParam("public_key"))
3811 ));
3812 }
3813
3814 #[test]
3822 fn package_actions_reject_a_local_file_target() {
3823 let reject: &[(&str, serde_json::Value)] = &[
3824 ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3825 ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3826 ("AptPurge", json!({"package": "./evil.deb"})),
3827 ("AptHold", json!({"package": "/tmp/evil.deb"})),
3828 ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3829 ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3830 ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3831 ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3832 (
3833 "ReplaceLayeredPackage",
3834 json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3835 ),
3836 (
3837 "ReplaceLayeredPackage",
3838 json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3839 ),
3840 ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3841 ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3842 ];
3843 for (action, params) in reject {
3844 assert!(
3845 matches!(
3846 build_action_spec(action, params),
3847 Err(ExecutorError::InvalidParam(_))
3848 ),
3849 "{action} must refuse a local-file package target: {params}"
3850 );
3851 }
3852 let accept: &[(&str, serde_json::Value)] = &[
3855 ("AptInstall", json!({"package": "nginx"})),
3856 ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3857 (
3858 "InstallPackages",
3859 json!({"packages": ["nginx", "python3-pip"]}),
3860 ),
3861 ];
3862 for (action, params) in accept {
3863 assert!(
3864 build_action_spec(action, params).is_ok(),
3865 "{action} must accept a real package spec: {params}"
3866 );
3867 }
3868 }
3869
3870 #[test]
3876 fn apt_package_actions_refuse_a_trailing_modifier() {
3877 let reject: &[(&str, &str)] = &[
3878 ("AptInstall", "linux-image-generic-"),
3879 ("AptInstall", "nginx-"),
3880 ("AptRemove", "nginx-"),
3881 ("AptRemove", "nginx+"),
3882 ("AptRemove", "g++"),
3883 ("AptPurge", "nginx-"),
3884 ("AptPurge", "linux-image-generic+"),
3885 ("AptHold", "nginx-"),
3886 ("AptHold", "nginx+"),
3887 ("AptUnhold", "nginx-"),
3888 ("AptUnhold", "nginx+"),
3889 ("AptShow", "nginx-"),
3890 ("AptInstall", "ubuntu-desktop^"),
3892 ("AptRemove", "ubuntu-desktop^"),
3893 ("AptPurge", "ubuntu-desktop^"),
3894 ("AptHold", "ubuntu-desktop^"),
3895 ("AptUnhold", "ubuntu-desktop^"),
3896 ("AptShow", "ubuntu-desktop^"),
3897 ];
3898 for (action, package) in reject {
3899 let result = build_action_spec(action, &json!({ "package": package }));
3900 assert!(
3901 matches!(result, Err(ExecutorError::InvalidParam("package"))),
3902 "{action} {package:?} must be refused, got {result:?}"
3903 );
3904 }
3905 let accept: &[(&str, &str)] = &[
3908 ("AptInstall", "g++"),
3909 ("AptInstall", "libstdc++6"),
3910 ("AptInstall", "nginx+"),
3911 ("AptRemove", "libstdc++6"),
3912 ("AptRemove", "g++:amd64"),
3913 ("AptPurge", "g++:amd64"),
3914 ("AptHold", "libstdc++6"),
3915 ("AptUnhold", "libstdc++6"),
3916 ("AptShow", "g++"),
3917 ("AptShow", "nginx/noble"),
3918 ];
3919 for (action, package) in accept {
3920 assert!(
3921 build_action_spec(action, &json!({ "package": package })).is_ok(),
3922 "{action} {package:?} must be accepted"
3923 );
3924 }
3925 }
3926
3927 #[test]
3932 fn activating_actions_refuse_a_root_shell_unit() {
3933 for action in ["StartService", "RestartService", "UnmaskService"] {
3934 for unit in [
3935 "debug-shell.service",
3936 "emergency.target",
3937 "rescue.service",
3938 "runlevel1.target",
3939 ] {
3940 assert!(
3941 matches!(
3942 build_action_spec(action, &json!({"unit": unit})),
3943 Err(ExecutorError::InvalidParam("unit"))
3944 ),
3945 "{action} must not activate {unit}"
3946 );
3947 }
3948 assert!(
3949 build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3950 "{action} must still activate an ordinary unit"
3951 );
3952 }
3953 assert!(
3954 build_action_spec(
3955 "SetServiceEnabled",
3956 &json!({"unit": "debug-shell.service", "enabled": true})
3957 )
3958 .is_err(),
3959 "enabling a root-shell unit brings it up at boot and must be refused"
3960 );
3961 assert!(
3962 build_action_spec(
3963 "SetServiceEnabled",
3964 &json!({"unit": "debug-shell.service", "enabled": false})
3965 )
3966 .is_ok(),
3967 "disabling a root-shell unit is a mitigation and must stay reachable"
3968 );
3969 for action in ["StopService", "MaskService"] {
3972 assert!(
3973 build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3974 "{action} on debug-shell is a mitigation and must stay allowed"
3975 );
3976 }
3977 }
3978
3979 #[test]
3980 fn authorized_keys_actions_reject_a_traversal_username() {
3981 for action in [
3988 "GetAuthorizedKeys",
3989 "AddAuthorizedKey",
3990 "RemoveAuthorizedKey",
3991 ] {
3992 for bad in ["../../etc", "..", "root/../../etc"] {
3993 let mut params = json!({"username": bad});
3994 if action != "GetAuthorizedKeys" {
3995 params["public_key"] =
3996 json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h");
3997 }
3998 let err = build_action_spec(action, ¶ms).unwrap_err();
3999 assert!(
4000 matches!(err, ExecutorError::InvalidParam("username")),
4001 "{action} must reject username {bad:?}, got {err:?}"
4002 );
4003 }
4004 }
4005 }
4006
4007 #[test]
4008 fn ip_taking_actions_reject_a_malformed_address() {
4009 let cases: [(&str, serde_json::Value); 3] = [
4012 (
4013 "ResolvectlSetDns",
4014 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
4015 ),
4016 (
4017 "Fail2banBanIp",
4018 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
4019 ),
4020 (
4021 "Fail2banUnbanIp",
4022 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
4023 ),
4024 ];
4025 for (action, params) in cases {
4026 let err = build_action_spec(action, ¶ms).unwrap_err();
4027 assert!(
4028 matches!(err, ExecutorError::InvalidIpAddress { .. }),
4029 "{action} must reject a malformed IP, got {err:?}"
4030 );
4031 }
4032 }
4033
4034 #[test]
4035 fn public_key_rejects_pipe_metacharacter() {
4036 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
4040 assert!(matches!(
4041 validated_public_key(key),
4042 Err(ExecutorError::InvalidParam("public_key"))
4043 ));
4044 }
4045
4046 #[test]
4047 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
4048 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
4055 assert!(validated_public_key(key).is_ok());
4056 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
4059 }
4060
4061 #[test]
4062 fn public_key_rejects_shell_metacharacters() {
4063 for (metachar, desc) in [
4064 (';', "semicolon"),
4065 ('`', "backtick"),
4066 ('$', "dollar"),
4067 ('\\', "backslash"),
4068 ('&', "ampersand"),
4069 ] {
4070 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
4071 assert!(
4072 matches!(
4073 validated_public_key(&key),
4074 Err(ExecutorError::InvalidParam("public_key"))
4075 ),
4076 "{desc} should be rejected"
4077 );
4078 }
4079 }
4080
4081 #[test]
4082 fn public_key_rejects_newline() {
4083 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
4084 assert!(matches!(
4085 validated_public_key(key),
4086 Err(ExecutorError::InvalidParam("public_key"))
4087 ));
4088 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
4089 assert!(matches!(
4090 validated_public_key(key_cr),
4091 Err(ExecutorError::InvalidParam("public_key"))
4092 ));
4093 }
4094
4095 #[test]
4096 fn public_key_rejects_too_long() {
4097 let long_payload = "A".repeat(8192);
4099 let key = format!("ssh-ed25519 {long_payload} user@host");
4100 assert!(matches!(
4101 validated_public_key(&key),
4102 Err(ExecutorError::InvalidParam("public_key"))
4103 ));
4104 }
4105
4106 #[test]
4109 fn str_array_or_empty_rejects_non_string_element() {
4110 let params = json!({ "packages": ["vim", 42, "curl"] });
4111 assert!(matches!(
4112 str_array_or_empty(¶ms, "packages"),
4113 Err(ExecutorError::InvalidParam("packages"))
4114 ));
4115 }
4116
4117 #[test]
4118 fn str_array_or_empty_accepts_string_array() {
4119 let params = json!({ "packages": ["vim", "curl"] });
4120 assert_eq!(
4121 str_array_or_empty(¶ms, "packages").unwrap(),
4122 vec!["vim".to_string(), "curl".to_string()]
4123 );
4124 }
4125
4126 #[test]
4127 fn str_array_or_empty_returns_empty_when_key_absent() {
4128 let params = json!({});
4129 assert_eq!(
4130 str_array_or_empty(¶ms, "packages").unwrap(),
4131 Vec::<String>::new()
4132 );
4133 }
4134
4135 #[test]
4138 fn kernel_arg_allows_safe_args() {
4139 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
4140 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
4141 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
4142 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
4143 }
4144
4145 #[test]
4146 fn kernel_arg_blocks_init_override() {
4147 assert!(matches!(
4148 validated_safe_kernel_arg("init=/bin/sh", "add"),
4149 Err(ExecutorError::InvalidParam("add"))
4150 ));
4151 assert!(matches!(
4152 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
4153 Err(ExecutorError::InvalidParam("add"))
4154 ));
4155 }
4156
4157 #[test]
4158 fn kernel_arg_blocks_selinux_disable() {
4159 assert!(matches!(
4160 validated_safe_kernel_arg("selinux=0", "add"),
4161 Err(ExecutorError::InvalidParam("add"))
4162 ));
4163 assert!(matches!(
4164 validated_safe_kernel_arg("enforcing=0", "add"),
4165 Err(ExecutorError::InvalidParam("add"))
4166 ));
4167 }
4168
4169 #[test]
4170 fn kernel_arg_blocks_security_override() {
4171 assert!(matches!(
4172 validated_safe_kernel_arg("security=none", "add"),
4173 Err(ExecutorError::InvalidParam("add"))
4174 ));
4175 }
4176
4177 #[test]
4178 fn kernel_arg_blocks_module_blacklist() {
4179 assert!(matches!(
4180 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
4181 Err(ExecutorError::InvalidParam("add"))
4182 ));
4183 }
4184
4185 #[test]
4193 fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
4194 for arg in [
4197 "lockdown=confidentiality",
4198 "module.sig_enforce=1",
4199 "selinux=1",
4200 "enforcing=1",
4201 "apparmor=1",
4202 "security=selinux",
4203 "pti=on",
4204 "mitigations=auto,nosmt",
4205 "init_on_alloc=1",
4206 "init_on_free=1",
4207 "slab_nomerge",
4208 "page_alloc.shuffle=1",
4209 "randomize_kstack_offset=on",
4210 "slub_debug=FZP",
4211 "vsyscall=none",
4212 "debugfs=off",
4213 "iommu=force",
4214 "intel_iommu=on",
4215 "amd_iommu=force_isolation",
4216 ] {
4217 assert!(
4218 matches!(
4219 validated_safe_kernel_arg_removal(arg, "remove"),
4220 Err(ExecutorError::InvalidParam("remove"))
4221 ),
4222 "removing {arg} is a boot-security downgrade and must be refused"
4223 );
4224 }
4225 }
4226
4227 #[test]
4233 fn kernel_arg_add_accepts_only_the_allowlist() {
4234 for arg in [
4235 "quiet",
4236 "splash",
4237 "rhgb",
4238 "nomodeset",
4239 "nowatchdog",
4240 "threadirqs",
4241 "nosmt",
4242 "slab_nomerge",
4243 "console=ttyS0,115200",
4244 "rd.driver.blacklist=nouveau",
4245 "transparent_hugepage=madvise",
4246 "nvidia-drm.modeset=1",
4247 "lockdown=integrity",
4248 "lockdown=confidentiality",
4249 "module.sig_enforce=1",
4250 "intel_iommu=on",
4251 "amd_iommu=force_isolation",
4252 "init_on_alloc=1",
4253 "init_on_free=1",
4254 "page_alloc.shuffle=1",
4255 "randomize_kstack_offset=on",
4256 "mitigations=auto",
4257 "mitigations=auto,nosmt",
4258 "pti=on",
4259 "vsyscall=none",
4260 "debugfs=off",
4261 "QUIET",
4262 ] {
4263 assert!(
4264 validated_safe_kernel_arg(arg, "add").is_ok(),
4265 "{arg} is on the allowlist and must be accepted"
4266 );
4267 }
4268 for arg in [
4269 "spectre-v2=off",
4271 "audit=off",
4272 "systemd.wants=debug-shell.service",
4273 "lsm=landlock",
4274 "mitigations=of",
4275 "nopku",
4276 "spectre_v2_user=off",
4277 "kpti=0",
4278 "its=off",
4279 "tsa=off",
4280 "vmscape=off",
4281 "noibrs",
4282 "noibpb",
4283 "audit=f",
4284 "foo=bar",
4286 "foo",
4287 "root=/dev/sdb1",
4288 "quiet=1",
4289 "console=",
4290 "lockdown=none",
4291 "mitigations=off",
4292 "mitigations=auto,nosmt,foo",
4293 "module.sig_enforce=0",
4294 "debugfs=on",
4295 ] {
4296 assert!(
4297 matches!(
4298 validated_safe_kernel_arg(arg, "add"),
4299 Err(ExecutorError::InvalidParam("add"))
4300 ),
4301 "{arg} is not on the allowlist and must be refused, naming `add`"
4302 );
4303 }
4304 }
4305
4306 #[test]
4312 fn kernel_arg_add_refuses_resume_and_limits_the_driver_blacklist() {
4313 for arg in [
4314 "resume=/dev/sdb1",
4315 "resume=/dev/mapper/luks-swap",
4316 "resume_offset=1",
4317 "rd.driver.blacklist=tpm_crb",
4318 "rd.driver.blacklist=nouveau,dm_crypt",
4319 "rd.driver.blacklist=",
4320 "rd.driver.blacklist=nouveau,",
4321 ] {
4322 assert!(
4323 matches!(
4324 validated_safe_kernel_arg(arg, "add"),
4325 Err(ExecutorError::InvalidParam("add"))
4326 ),
4327 "{arg} must be refused"
4328 );
4329 }
4330 for arg in [
4331 "rd.driver.blacklist=nouveau",
4332 "rd.driver.blacklist=nouveau,nvidia,nvidia_drm,nvidia_modeset",
4333 "rd.driver.blacklist=radeon,amdgpu,i915",
4334 ] {
4335 assert!(
4336 validated_safe_kernel_arg(arg, "add").is_ok(),
4337 "{arg} must be accepted"
4338 );
4339 }
4340 for arg in ["resume", "resume=/dev/mapper/luks-swap", "resume_offset"] {
4341 assert!(
4342 validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4343 "{arg} is not on the allowlist, so removing it is refused"
4344 );
4345 }
4346 assert!(validated_safe_kernel_arg_removal("rd.driver.blacklist=nouveau", "remove").is_ok());
4347 }
4348
4349 #[test]
4353 fn kernel_arg_remove_accepts_only_ordinary_keys_and_known_downgrades() {
4354 for arg in [
4355 "quiet",
4356 "rhgb",
4357 "quiet=1",
4358 "console=ttyS0",
4359 "rd.driver.blacklist=nouveau",
4360 "mitigations=off",
4361 "init=/bin/sh",
4362 "single",
4363 "nokaslr",
4364 "spectre_v2=off",
4365 ] {
4366 assert!(
4367 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4368 "{arg} must stay removable"
4369 );
4370 }
4371 for arg in [
4372 "lockdown",
4373 "module.sig_enforce",
4374 "lockdown=integrity",
4375 "nosmt",
4376 "slab_nomerge",
4377 "vsyscall=none",
4378 "mitigations=auto",
4379 "foo=bar",
4380 "foo",
4381 "mitigations=of",
4382 ] {
4383 assert!(
4384 matches!(
4385 validated_safe_kernel_arg_removal(arg, "remove"),
4386 Err(ExecutorError::InvalidParam("remove"))
4387 ),
4388 "removing {arg} must be refused, naming `remove`"
4389 );
4390 }
4391 }
4392
4393 #[test]
4398 fn kernel_arg_screens_treat_dash_and_underscore_alike_in_the_key() {
4399 for arg in [
4400 "spectre-v2=off",
4401 "module-blacklist=apparmor",
4402 "Spec-Store-Bypass-Disable=off",
4403 "systemd.debug-shell=1",
4404 "rd.systemd.debug-shell",
4405 "nospectre-v2",
4406 "nospectre-bhb",
4407 "tsx-async-abort=off",
4408 ] {
4409 assert!(
4410 validated_safe_kernel_arg(arg, "add").is_err(),
4411 "{arg} is an underscore switch spelled with hyphens and must be refused"
4412 );
4413 }
4414 for arg in [
4415 "module.sig-enforce=1",
4416 "init-on-alloc=1",
4417 "randomize-kstack-offset=on",
4418 "page-alloc.shuffle=1",
4419 ] {
4420 assert!(
4421 validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4422 "{arg} is a protection spelled with hyphens and must not be removable"
4423 );
4424 }
4425 for arg in ["init-on-alloc=0", "module.sig-enforce=0"] {
4426 assert!(
4427 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4428 "{arg} is a weakening spelled with hyphens and must stay removable"
4429 );
4430 }
4431 assert!(validated_safe_kernel_arg("systemd.unit=debug-shell.service", "add").is_err());
4432 assert!(validated_safe_kernel_arg("nvidia-drm.modeset=1", "add").is_ok());
4434 }
4435
4436 #[test]
4439 fn kernel_arg_blocks_every_spelling_of_off_for_a_protection_switch() {
4440 for arg in [
4441 "audit=off",
4442 "audit=0",
4443 "audit=n",
4444 "audit=no",
4445 "audit=false",
4446 "AUDIT=OFF",
4447 "mitigations=false",
4448 "pti=no",
4449 "spectre_v2=n",
4450 "mds=off",
4451 "tsx_async_abort=off",
4452 "mmio_stale_data=off",
4453 "retbleed=off",
4454 "srbds=off",
4455 "l1tf=off",
4456 "spectre_bhi=off",
4457 "gather_data_sampling=off",
4458 "reg_file_data_sampling=off",
4459 "module.sig_enforce=no",
4460 "init_on_free=false",
4461 "nospec_store_bypass_disable",
4462 "nospectre_bhb",
4463 ] {
4464 assert!(
4465 matches!(
4466 validated_safe_kernel_arg(arg, "add"),
4467 Err(ExecutorError::InvalidParam("add"))
4468 ),
4469 "{arg} switches a protection off and must be refused"
4470 );
4471 }
4472 for arg in [
4473 "audit=1",
4474 "mds=full",
4475 "retbleed=auto",
4476 "l1tf=flush",
4477 "spectre_bhi=on",
4478 ] {
4479 assert!(
4480 validated_safe_kernel_arg(arg, "add").is_err(),
4481 "{arg} is not on the allowlist"
4482 );
4483 }
4484 for arg in ["mitigations=auto", "pti=on"] {
4485 assert!(
4486 validated_safe_kernel_arg(arg, "add").is_ok(),
4487 "{arg} must stay valid"
4488 );
4489 }
4490 }
4491
4492 #[test]
4495 fn kernel_arg_removal_protects_the_mitigation_switches() {
4496 for arg in [
4497 "mds=full",
4498 "tsx_async_abort=full",
4499 "mmio_stale_data=full",
4500 "retbleed=auto",
4501 "srbds=on",
4502 "l1tf=flush",
4503 "spectre_bhi=on",
4504 "gather_data_sampling=force",
4505 "reg_file_data_sampling=on",
4506 "spectre_v2=on",
4507 "spec_store_bypass_disable=on",
4508 "mds",
4509 ] {
4510 assert!(
4511 validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4512 "removing {arg} weakens the next boot and must be refused"
4513 );
4514 }
4515 for arg in ["mds=off", "retbleed=off", "spectre_v2=off"] {
4516 assert!(
4517 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4518 "{arg} is a weakening, so removing it must stay allowed"
4519 );
4520 }
4521 for arg in ["audit=false", "pti=no"] {
4523 assert!(
4524 validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4525 "{arg} is not in KARG_WEAKENING"
4526 );
4527 }
4528 }
4529
4530 #[test]
4536 fn kernel_arg_removal_refuses_a_protective_bare_key() {
4537 for arg in [
4538 "lockdown",
4539 "module.sig_enforce",
4540 "selinux",
4541 "enforcing",
4542 "apparmor",
4543 "security",
4544 "iommu",
4545 "mitigations",
4546 "init_on_alloc",
4547 "lsm",
4548 "audit",
4549 "LOCKDOWN",
4550 "kaslr=1",
4551 ] {
4552 assert!(
4553 matches!(
4554 validated_safe_kernel_arg_removal(arg, "remove"),
4555 Err(ExecutorError::InvalidParam("remove"))
4556 ),
4557 "removing the bare key {arg} removes its protective value and must be refused"
4558 );
4559 }
4560 for arg in ["mitigations=off", "selinux=0", "audit=0", "quiet", "rhgb"] {
4562 assert!(
4563 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4564 "{arg} must stay removable"
4565 );
4566 }
4567 }
4568
4569 #[test]
4572 fn kernel_arg_removal_refuses_the_lsm_and_audit_selectors() {
4573 for arg in ["lsm=landlock,lockdown,yama,apparmor", "audit=1", "Audit=1"] {
4574 assert!(
4575 validated_safe_kernel_arg_removal(arg, "remove").is_err(),
4576 "removing {arg} weakens the boot and must be refused"
4577 );
4578 }
4579 }
4580
4581 #[test]
4585 fn kernel_arg_blocks_root_shell_units_pulled_in_by_wants() {
4586 for arg in [
4587 "systemd.wants=debug-shell.service",
4588 "systemd.wants=emergency.service",
4589 "rd.systemd.wants=rescue.target",
4590 "rd.systemd.wants=debug-shell.service",
4591 "SYSTEMD.WANTS=Debug-Shell.service",
4592 ] {
4593 assert!(
4594 validated_safe_kernel_arg(arg, "add").is_err(),
4595 "{arg} boots into a root shell and must be refused"
4596 );
4597 }
4598 assert!(validated_safe_kernel_arg("systemd.wants=sshd.service", "add").is_err());
4600 }
4601
4602 #[test]
4606 fn kernel_arg_blocks_the_remaining_weakenings() {
4607 for arg in [
4608 "lsm=landlock",
4609 "rd.shell",
4610 "rd.shell=1",
4611 "systemd.mask=auditd.service",
4612 "rd.systemd.mask=systemd-journald.service",
4613 "modprobe.blacklist=apparmor",
4614 "audit=0",
4615 "nopti",
4616 "nospectre_v1",
4617 "nospectre_v2",
4618 "spectre_v2=off",
4619 "spec_store_bypass_disable=off",
4620 "nokaslr",
4621 "NOPTI",
4622 ] {
4623 assert!(
4624 matches!(
4625 validated_safe_kernel_arg(arg, "add"),
4626 Err(ExecutorError::InvalidParam("add"))
4627 ),
4628 "{arg} weakens the next boot and must be refused"
4629 );
4630 }
4631 for arg in ["spectre_v2=on", "audit=1"] {
4632 assert!(
4633 validated_safe_kernel_arg(arg, "add").is_err(),
4634 "{arg} is not on the allowlist"
4635 );
4636 }
4637 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
4638 }
4639
4640 #[test]
4644 fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
4645 for arg in [
4646 "mitigations=off",
4647 "selinux=0",
4648 "enforcing=0",
4649 "apparmor=0",
4650 "pti=off",
4651 "lockdown=none",
4652 "debugfs=on",
4653 "init=/bin/sh",
4654 "single",
4655 ] {
4656 assert!(
4657 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4658 "{arg} weakens the host, so removing it must stay allowed"
4659 );
4660 }
4661 }
4662
4663 #[test]
4664 fn kernel_arg_removal_allows_ordinary_args() {
4665 for arg in [
4666 "quiet",
4667 "splash",
4668 "nomodeset",
4669 "console=ttyS0,115200",
4670 "rd.driver.blacklist=nouveau",
4671 ] {
4672 assert!(
4673 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4674 "{arg} carries no boot-security meaning and must stay removable"
4675 );
4676 }
4677 }
4678
4679 const NOT_ONE_KERNEL_TOKEN: &[&str] = &[
4684 "quiet init=/bin/sh",
4685 "x selinux=0",
4686 "quiet\tmitigations=off",
4687 "quiet\ninit=/bin/sh",
4688 "quiet\rsingle",
4689 "\u{0}nomodeset",
4690 "nomodeset\u{0}",
4691 "quiet\u{7f}",
4692 "quiet\u{a0}single",
4693 "console=\"ttyS0 init=/bin/sh\"",
4694 "quiet'",
4695 "quiet\\",
4696 "-b",
4697 "-quiet",
4698 "",
4699 ];
4700
4701 #[test]
4702 fn set_kernel_arguments_refuses_a_value_that_is_not_one_token() {
4703 for arg in NOT_ONE_KERNEL_TOKEN {
4704 for key in ["add", "remove"] {
4705 assert!(
4706 build_action_spec("SetKernelArguments", &json!({ key: [arg] })).is_err(),
4707 "SetKernelArguments must refuse {key} {arg:?}"
4708 );
4709 }
4710 }
4711 let too_long = "a".repeat(crate::actions::validate::SAFE_ARG_MAX_BYTES + 1);
4712 for key in ["add", "remove"] {
4713 assert!(
4714 build_action_spec("SetKernelArguments", &json!({ key: [too_long] })).is_err(),
4715 "SetKernelArguments must refuse an over-long {key} value"
4716 );
4717 }
4718 }
4719
4720 #[test]
4721 fn set_kernel_arguments_keeps_ordinary_tokens() {
4722 for arg in [
4723 "quiet",
4724 "nomodeset",
4725 "console=ttyS0,115200",
4726 "rd.driver.blacklist=nouveau",
4727 "video=HDMI-A-1:1920x1080@60",
4728 "nvidia-drm.modeset=1",
4729 ] {
4730 for key in ["add", "remove"] {
4731 assert!(
4732 build_action_spec("SetKernelArguments", &json!({ key: [arg] })).is_ok(),
4733 "SetKernelArguments must keep accepting {key} {arg:?}"
4734 );
4735 }
4736 }
4737 for key in ["add", "remove"] {
4740 assert!(
4741 build_action_spec(
4742 "SetKernelArguments",
4743 &json!({ key: ["root=UUID=0b1c-77aa"] })
4744 )
4745 .is_err(),
4746 "SetKernelArguments must refuse {key} root="
4747 );
4748 }
4749 }
4750
4751 #[test]
4752 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
4753 assert!(matches!(
4754 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
4755 Err(ExecutorError::InvalidParam("add"))
4756 ));
4757 assert!(matches!(
4758 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
4759 Err(ExecutorError::InvalidParam("add"))
4760 ));
4761 assert!(matches!(
4762 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
4763 Err(ExecutorError::InvalidParam("add"))
4764 ));
4765 }
4766
4767 #[test]
4775 fn kernel_arg_blocks_boot_mode_and_run_tokens() {
4776 for arg in [
4777 "emergency",
4778 "rescue",
4779 "-b",
4780 "rd.emergency",
4781 "rd.rescue",
4782 "2",
4783 "3",
4784 "4",
4785 "5",
4786 "systemd.run=/tmp/x",
4787 "systemd.run_success_action=reboot",
4788 "systemd.run_failure_action=none",
4789 "rdinit=/bin/sh",
4790 "rd.break",
4791 "rd.break=pre-mount",
4792 "break",
4793 "break=premount",
4794 "rd.systemd.unit=emergency.target",
4795 "rd.systemd.unit=rescue.target",
4796 "rd.systemd.unit=debug-shell.service",
4797 "EMERGENCY",
4798 "Systemd.Run=/tmp/x",
4799 ] {
4800 assert!(
4801 matches!(
4802 validated_safe_kernel_arg(arg, "add"),
4803 Err(ExecutorError::InvalidParam("add"))
4804 ),
4805 "{arg} boots into a root shell or runs a root command and must be refused"
4806 );
4807 }
4808 assert!(validated_safe_kernel_arg("rd.systemd.unit=graphical.target", "add").is_err());
4810 for arg in ["quiet", "splash", "loglevel=3"] {
4811 assert!(
4812 validated_safe_kernel_arg(arg, "add").is_ok(),
4813 "{arg} carries no boot-security meaning and must stay appendable"
4814 );
4815 }
4816 }
4817
4818 #[test]
4819 fn kernel_arg_blocks_apparmor_disable() {
4820 assert!(matches!(
4821 validated_safe_kernel_arg("apparmor=0", "add"),
4822 Err(ExecutorError::InvalidParam("add"))
4823 ));
4824 }
4825
4826 #[test]
4827 fn kernel_arg_blocks_mitigations_off() {
4828 assert!(matches!(
4829 validated_safe_kernel_arg("mitigations=off", "add"),
4830 Err(ExecutorError::InvalidParam("add"))
4831 ));
4832 }
4833
4834 #[test]
4835 fn kernel_arg_blocks_lockdown_override() {
4836 assert!(matches!(
4837 validated_safe_kernel_arg("lockdown=none", "add"),
4838 Err(ExecutorError::InvalidParam("add"))
4839 ));
4840 }
4841
4842 #[test]
4843 fn kernel_arg_blocks_pti_off() {
4844 assert!(matches!(
4845 validated_safe_kernel_arg("pti=off", "add"),
4846 Err(ExecutorError::InvalidParam("add"))
4847 ));
4848 }
4849
4850 #[test]
4851 fn kernel_arg_blocks_smap_smep_disable() {
4852 assert!(matches!(
4853 validated_safe_kernel_arg("nosmap", "add"),
4854 Err(ExecutorError::InvalidParam("add"))
4855 ));
4856 assert!(matches!(
4857 validated_safe_kernel_arg("nosmep", "add"),
4858 Err(ExecutorError::InvalidParam("add"))
4859 ));
4860 }
4861
4862 #[test]
4863 fn kernel_arg_blocks_single_user_shortcuts() {
4864 assert!(matches!(
4866 validated_safe_kernel_arg("single", "add"),
4867 Err(ExecutorError::InvalidParam("add"))
4868 ));
4869 assert!(matches!(
4870 validated_safe_kernel_arg("s", "add"),
4871 Err(ExecutorError::InvalidParam("add"))
4872 ));
4873 assert!(matches!(
4874 validated_safe_kernel_arg("1", "add"),
4875 Err(ExecutorError::InvalidParam("add"))
4876 ));
4877 }
4878
4879 #[test]
4880 fn signal_process_guardrails() {
4881 for bad_pid in [0, 1] {
4883 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
4884 assert!(
4885 matches!(err, ExecutorError::InvalidParam("pid")),
4886 "pid {bad_pid} must be rejected, got {err:?}"
4887 );
4888 }
4889 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
4891 .unwrap_err();
4892 assert!(
4893 matches!(err, ExecutorError::InvalidParam("signal")),
4894 "got {err:?}"
4895 );
4896 assert!(matches!(
4898 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
4899 ExecutorError::MissingParam("pid")
4900 ));
4901 let spec = build_action_spec(
4903 "SignalProcess",
4904 &json!({ "pid": "4242", "signal": "sigkill" }),
4905 )
4906 .unwrap();
4907 assert_eq!(spec.action_name, "SignalProcess");
4908 assert_eq!(
4909 spec.mechanism,
4910 ActionMechanism::Command {
4911 program: "sudo",
4912 args: vec![
4913 "/usr/lib/sysknife/action-steps".to_string(),
4914 "signal".to_string(),
4915 "KILL".to_string(),
4916 "4242".to_string()
4917 ],
4918 }
4919 );
4920 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
4922 if let ActionMechanism::Command { args, .. } = spec.mechanism {
4923 assert_eq!(
4924 args,
4925 vec!["/usr/lib/sysknife/action-steps", "signal", "TERM", "4242"]
4926 );
4927 } else {
4928 panic!("expected Command mechanism");
4929 }
4930 }
4931
4932 #[test]
4933 fn create_scheduled_job_validates_name_command_schedule() {
4934 assert!(matches!(
4936 build_action_spec(
4937 "CreateScheduledJob",
4938 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
4939 )
4940 .unwrap_err(),
4941 ExecutorError::InvalidParam("command")
4942 ));
4943 assert!(matches!(
4945 build_action_spec(
4946 "CreateScheduledJob",
4947 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
4948 )
4949 .unwrap_err(),
4950 ExecutorError::InvalidParam("name")
4951 ));
4952 assert!(matches!(
4954 build_action_spec(
4955 "CreateScheduledJob",
4956 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
4957 )
4958 .unwrap_err(),
4959 ExecutorError::InvalidParam("schedule")
4960 ));
4961 let spec = build_action_spec(
4963 "CreateScheduledJob",
4964 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
4965 )
4966 .unwrap();
4967 assert_eq!(
4968 spec.mechanism,
4969 ActionMechanism::Command {
4970 program: "sudo",
4971 args: vec![
4972 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
4973 "--name".to_string(),
4974 "nightly-backup".to_string(),
4975 "--command".to_string(),
4976 "/usr/bin/backup --full".to_string(),
4977 "--schedule".to_string(),
4978 "*-*-* 02:00:00".to_string(),
4979 ],
4980 }
4981 );
4982 }
4983
4984 #[test]
4985 fn set_sshd_option_allowlist_guardrails() {
4986 assert!(matches!(
4988 build_action_spec(
4989 "SetSshdOption",
4990 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
4991 )
4992 .unwrap_err(),
4993 ExecutorError::InvalidParam("option")
4994 ));
4995 assert!(matches!(
4997 build_action_spec(
4998 "SetSshdOption",
4999 &json!({ "option": "PermitRootLogin", "value": "maybe" })
5000 )
5001 .unwrap_err(),
5002 ExecutorError::InvalidParam("value")
5003 ));
5004 let spec = build_action_spec(
5006 "SetSshdOption",
5007 &json!({ "option": "PasswordAuthentication", "value": "no" }),
5008 )
5009 .unwrap();
5010 assert_eq!(
5011 spec.mechanism,
5012 ActionMechanism::Command {
5013 program: "sudo",
5014 args: vec![
5015 "/usr/lib/sysknife/sshd-option-edit".to_string(),
5016 "--option".to_string(),
5017 "PasswordAuthentication".to_string(),
5018 "--value".to_string(),
5019 "no".to_string(),
5020 ],
5021 }
5022 );
5023 }
5024
5025 #[test]
5026 fn configure_unattended_upgrades_toggles_helper_flag() {
5027 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
5028 let spec = build_action_spec(
5029 "ConfigureUnattendedUpgrades",
5030 &json!({ "enabled": enabled }),
5031 )
5032 .unwrap();
5033 assert_eq!(
5034 spec.mechanism,
5035 ActionMechanism::Command {
5036 program: "sudo",
5037 args: vec![
5038 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
5039 flag.to_string(),
5040 ],
5041 }
5042 );
5043 }
5044 assert!(matches!(
5045 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
5046 ExecutorError::MissingParam("enabled")
5047 ));
5048 }
5049
5050 #[test]
5051 fn grub_set_kargs_append_blocks_single_user_shortcut() {
5052 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
5060 let err = build_action_spec(
5061 "GrubSetKargs",
5062 &json!({ "append": [dangerous], "delete": [] }),
5063 )
5064 .unwrap_err();
5065 assert!(
5066 matches!(err, ExecutorError::InvalidParam("append")),
5067 "GrubSetKargs must reject append=[{dangerous:?}] via the allowlist, got {err:?}"
5068 );
5069 }
5070 assert!(build_action_spec(
5072 "GrubSetKargs",
5073 &json!({ "append": ["quiet"], "delete": [] })
5074 )
5075 .is_ok());
5076 }
5077
5078 #[test]
5079 fn kernel_arg_build_spec_rejects_dangerous_arg() {
5080 let err = build_action_spec(
5085 "SetKernelArguments",
5086 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
5087 )
5088 .unwrap_err();
5089 assert!(
5090 matches!(err, ExecutorError::InvalidParam("add")),
5091 "expected InvalidParam(add), got {err:?}"
5092 );
5093 }
5094
5095 #[test]
5096 fn grub_set_kargs_cannot_delete_a_protective_karg() {
5097 for karg in [
5104 "apparmor=1",
5105 "lockdown=confidentiality",
5106 "mitigations=auto",
5107 "pti=on",
5108 "selinux=1",
5109 ] {
5110 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
5111 .unwrap_err();
5112 assert!(
5113 matches!(err, ExecutorError::InvalidParam("delete")),
5114 "deleting {karg:?} must be refused, got {err:?}"
5115 );
5116 }
5117 }
5118
5119 #[test]
5124 fn rollback_available_matches_rollback_spec_for_all_actions() {
5125 for spec in crate::actions::all_specs() {
5133 let has_rollback = rollback_spec_for(spec.action_name).is_some();
5134 assert_eq!(
5135 spec.rollback_available,
5136 has_rollback,
5137 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
5138 spec.action_name,
5139 spec.rollback_available,
5140 if has_rollback { "Some" } else { "None" },
5141 );
5142 }
5143 }
5144
5145 #[test]
5153 fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
5154 for home in [
5155 "../../etc/skel",
5156 "/home/../etc",
5157 "home/alice",
5158 "/home/alice/../../../root",
5159 ] {
5160 let got =
5161 build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
5162 assert!(
5163 got.is_err(),
5164 "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
5165 );
5166 }
5167 build_action_spec(
5170 "CreateUser",
5171 &json!({ "username": "alice", "home": "/home/alice" }),
5172 )
5173 .expect("a plain absolute home must still be accepted");
5174 }
5175
5176 #[test]
5182 fn create_user_is_high_risk() {
5183 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
5184 assert_eq!(
5185 spec.risk_level,
5186 RiskLevel::High,
5187 "CreateUser must be High (T1136.001 Persistence)"
5188 );
5189 }
5190
5191 #[test]
5192 fn configure_firewall_is_high_risk() {
5193 let spec = build_action_spec(
5194 "ConfigureFirewall",
5195 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
5196 )
5197 .unwrap();
5198 assert_eq!(
5199 spec.risk_level,
5200 RiskLevel::High,
5201 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
5202 );
5203 }
5204
5205 #[test]
5206 fn mask_service_is_high_risk() {
5207 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
5208 assert_eq!(
5209 spec.risk_level,
5210 RiskLevel::High,
5211 "MaskService must be High (T1562.001 Impair Defenses)"
5212 );
5213 }
5214
5215 #[test]
5216 fn add_package_repository_is_high_risk() {
5217 let spec = build_action_spec(
5218 "AddPackageRepository",
5219 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
5220 )
5221 .unwrap();
5222 assert_eq!(
5223 spec.risk_level,
5224 RiskLevel::High,
5225 "AddPackageRepository must be High (supply-chain vector)"
5226 );
5227 }
5228
5229 #[test]
5230 fn set_dns_servers_is_high_risk() {
5231 let spec = build_action_spec(
5232 "SetDnsServers",
5233 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
5234 )
5235 .unwrap();
5236 assert_eq!(
5237 spec.risk_level,
5238 RiskLevel::High,
5239 "SetDnsServers must be High (DNS hijacking / T1557)"
5240 );
5241 }
5242}