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_audit_path, validated_audit_perms, validated_cpu_quota, validated_domain,
11 validated_email, validated_fstype, validated_group, validated_group_not_critical,
12 validated_home_dir, validated_hostname, validated_install_package, validated_journal_grep,
13 validated_journal_priority, validated_journal_time, validated_locale, validated_log_path,
14 validated_lvm_name, validated_lvm_size, validated_memory_limit, validated_mount_device,
15 validated_mount_options, validated_mount_point, validated_port_or_service,
16 validated_ppa_name, validated_pro_service, validated_resource_limit_unit,
17 validated_safe_arg, validated_sudo_commands, validated_sudoers_name, validated_swap_path,
18 validated_sysctl_key, validated_sysctl_value, validated_syslog_host, validated_tasks_max,
19 validated_timezone, validated_unit_name, validated_username,
20 validated_username_not_critical,
21 },
22 ActionMechanism, ActionSpec,
23};
24use async_trait::async_trait;
25use serde_json::Value;
26use std::io;
27use std::net::IpAddr;
28use std::process::Stdio;
29use std::str::FromStr;
30use std::time::Duration;
31use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
32use tokio::sync::mpsc::UnboundedSender;
33
34const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
45const MAX_SCHEDULE_EXPR_LEN: usize = 128;
47const MAX_JOURNAL_LINES: u64 = 10_000;
50const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
52const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
54const MAX_PASSWORD_MINLEN: u64 = 128;
56const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
58const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
60
61#[derive(Debug, thiserror::Error)]
62pub enum ExecutorError {
63 #[error("unknown action: {0}")]
64 UnknownAction(String),
65
66 #[error("missing required param: {0}")]
67 MissingParam(&'static str),
68
69 #[error("invalid param type for: {0}")]
70 InvalidParam(&'static str),
71
72 #[error("invalid IP address for param '{param}': '{value}'")]
78 InvalidIpAddress { param: &'static str, value: String },
79
80 #[error("io error: {0}")]
81 Io(#[from] io::Error),
82
83 #[error(
92 "{program} exceeded the {timeout_secs}s action timeout and could not be \
93 confirmed stopped: process group {pgid} still has members. Automatic \
94 rollback was skipped because the original action may still be running; \
95 inspect the host before retrying"
96 )]
97 ActionNotStopped {
98 program: String,
99 pgid: i32,
100 timeout_secs: u64,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ExecutionOutput {
114 pub stdout: String,
115 pub stderr: String,
116 pub exit_code: i32,
117}
118
119impl ExecutionOutput {
120 pub fn is_success(&self) -> bool {
122 self.exit_code == 0
123 }
124
125 pub fn is_nonzero(&self) -> bool {
127 self.exit_code != 0
128 }
129}
130
131#[async_trait]
138pub trait ActionExecutor: Send + Sync {
139 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
141
142 async fn execute_with_progress(
148 &self,
149 spec: &ActionSpec,
150 progress: UnboundedSender<String>,
151 ) -> Result<ExecutionOutput, ExecutorError> {
152 let output = self.execute(spec).await?;
153 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
154 let _ = progress.send(line.to_string());
155 }
156 Ok(output)
157 }
158}
159
160pub struct RealActionExecutor;
162
163#[async_trait]
164impl ActionExecutor for RealActionExecutor {
165 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
166 execute_spec(spec).await
167 }
168
169 async fn execute_with_progress(
170 &self,
171 spec: &ActionSpec,
172 progress: UnboundedSender<String>,
173 ) -> Result<ExecutionOutput, ExecutorError> {
174 match &spec.mechanism {
175 ActionMechanism::Command { program, args } => {
176 execute_command_with_progress(program, args, progress).await
177 }
178 _ => execute_spec(spec).await,
179 }
180 }
181}
182
183const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
197
198fn action_timeout() -> Duration {
199 match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
200 Ok(raw) => match raw.trim().parse::<u64>() {
201 Ok(secs) if secs > 0 => Duration::from_secs(secs),
202 _ => {
203 eprintln!(
204 "[sysknife-daemon] WARNING: ignoring invalid \
205 SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
206 );
207 DEFAULT_ACTION_TIMEOUT
208 }
209 },
210 Err(_) => DEFAULT_ACTION_TIMEOUT,
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216enum GroupState {
217 Gone,
219 AliveReachable,
221 AliveUnreachable,
226 Unknown(i32),
229}
230
231impl GroupState {
232 fn is_gone(self) -> bool {
233 matches!(self, GroupState::Gone)
234 }
235}
236
237fn probe_group(pgid: i32) -> GroupState {
239 let rc = unsafe { libc::killpg(pgid, 0) };
244 if rc == 0 {
245 return GroupState::AliveReachable;
246 }
247 match io::Error::last_os_error().raw_os_error() {
248 Some(libc::ESRCH) => GroupState::Gone,
249 Some(libc::EPERM) => GroupState::AliveUnreachable,
250 other => GroupState::Unknown(other.unwrap_or(0)),
251 }
252}
253
254fn signal_group(pgid: i32, signal: i32) -> Result<(), io::Error> {
256 let rc = unsafe { libc::killpg(pgid, signal) };
258 if rc == 0 {
259 return Ok(());
260 }
261 let err = io::Error::last_os_error();
262 if err.raw_os_error() == Some(libc::ESRCH) {
263 return Ok(());
264 }
265 Err(err)
266}
267
268const GROUP_TERM_GRACE: Duration = Duration::from_secs(5);
273
274fn is_rpm_ostree_action(program: &str, args: &[String]) -> bool {
280 let is_ostree = |s: &str| s == "rpm-ostree" || s.ends_with("/rpm-ostree");
281 is_ostree(program) || args.iter().any(|a| is_ostree(a))
282}
283
284fn root_group_kill_argv(signal: &str, pgid: i32) -> Vec<String> {
290 vec![
291 "-n".to_string(),
292 "/usr/bin/kill".to_string(),
293 "-s".to_string(),
294 signal.to_string(),
295 "--".to_string(),
296 format!("-{pgid}"),
297 ]
298}
299
300#[cfg(test)]
303static SUDO_INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
304
305async fn run_sudo(args: Vec<String>) {
312 #[cfg(test)]
313 SUDO_INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
314 let fut = tokio::process::Command::new("sudo")
315 .args(&args)
316 .stdin(Stdio::null())
317 .stdout(Stdio::null())
318 .stderr(Stdio::null())
319 .status();
320 let joined = args.join(" ");
321 match tokio::time::timeout(REAP_WAIT_CAP, fut).await {
322 Ok(Ok(status)) if status.success() => {}
323 Ok(Ok(status)) => {
324 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` exited {status}")
325 }
326 Ok(Err(e)) => {
327 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` could not run: {e}")
328 }
329 Err(_) => eprintln!(
330 "[sysknife-daemon] privileged reap `sudo {joined}` timed out after {}s",
331 REAP_WAIT_CAP.as_secs()
332 ),
333 }
334}
335
336fn reap_command(pgid: i32, program: &str, args: &[String]) -> Vec<String> {
342 if is_rpm_ostree_action(program, args) {
343 vec![
344 "-n".to_string(),
345 "/usr/bin/rpm-ostree".to_string(),
346 "cancel".to_string(),
347 ]
348 } else {
349 root_group_kill_argv("TERM", pgid)
350 }
351}
352
353async fn privileged_reap(pgid: i32, program: &str, args: &[String]) {
359 run_sudo(reap_command(pgid, program, args)).await;
360 if is_rpm_ostree_action(program, args) {
367 return;
368 }
369 tokio::time::sleep(Duration::from_millis(300)).await;
370 if !probe_group(pgid).is_gone() {
371 run_sudo(root_group_kill_argv("KILL", pgid)).await;
372 }
373}
374
375const REAP_WAIT_CAP: Duration = Duration::from_secs(2);
383
384async fn kill_and_reap(
402 child: &mut tokio::process::Child,
403 program: &str,
404 args: &[String],
405 cause: StopCause,
406) -> ExecutorError {
407 let secs = action_timeout().as_secs();
408 let why = cause.describe(secs);
409 let Some(pgid) = child.id().map(|pid| pid as i32) else {
410 eprintln!("[sysknife-daemon] {program} {why}; already exited");
413 return cause.into_error(program, secs);
414 };
415
416 let _ = child.start_kill();
420 if let Err(err) = signal_group(pgid, libc::SIGTERM) {
421 eprintln!("[sysknife-daemon] SIGTERM to process group {pgid} failed: {err}");
422 }
423
424 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
428
429 let grace = tokio::time::Instant::now() + GROUP_TERM_GRACE;
430 while tokio::time::Instant::now() < grace && !probe_group(pgid).is_gone() {
431 tokio::time::sleep(Duration::from_millis(50)).await;
432 }
433 if !probe_group(pgid).is_gone() {
434 if let Err(err) = signal_group(pgid, libc::SIGKILL) {
435 eprintln!("[sysknife-daemon] SIGKILL to process group {pgid} failed: {err}");
436 }
437 tokio::time::sleep(Duration::from_millis(200)).await;
438 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
439 }
440
441 if probe_group(pgid) == GroupState::AliveUnreachable {
457 eprintln!(
458 "[sysknife-daemon] {program} process group {pgid} survived as root; \
459 escalating to a privileged reap"
460 );
461 privileged_reap(pgid, program, args).await;
462 tokio::time::sleep(Duration::from_millis(200)).await;
463 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
464 }
465
466 match probe_group(pgid) {
467 GroupState::Gone => {
468 eprintln!("[sysknife-daemon] {program} {why}; process group {pgid} stopped");
469 cause.into_error(program, secs)
470 }
471 state => {
472 eprintln!(
473 "[sysknife-daemon] {program} {why} and process group {pgid} \
474 could not be confirmed stopped ({state:?})"
475 );
476 ExecutorError::ActionNotStopped {
477 program: program.to_string(),
478 pgid,
479 timeout_secs: secs,
480 }
481 }
482 }
483}
484
485enum StopCause {
493 Timeout,
495 Unreadable(io::Error),
499}
500
501impl StopCause {
502 fn describe(&self, secs: u64) -> String {
504 match self {
505 StopCause::Timeout => format!("exceeded the {secs}s action timeout"),
506 StopCause::Unreadable(err) => format!("could not be read ({err})"),
507 }
508 }
509
510 fn into_error(self, program: &str, secs: u64) -> ExecutorError {
515 match self {
516 StopCause::Timeout => ExecutorError::Io(io::Error::new(
517 io::ErrorKind::TimedOut,
518 format!("{program} exceeded the {secs}s action timeout and was stopped"),
519 )),
520 StopCause::Unreadable(err) => ExecutorError::Io(err),
521 }
522 }
523}
524
525async fn execute_command_with_progress(
526 program: &'static str,
527 args: &[String],
528 progress: UnboundedSender<String>,
529) -> Result<ExecutionOutput, ExecutorError> {
530 let mut child = tokio::process::Command::new(program)
531 .args(args)
532 .process_group(0)
537 .stdin(Stdio::null())
542 .stdout(Stdio::piped())
543 .stderr(Stdio::piped())
544 .spawn()
545 .map_err(ExecutorError::Io)?;
546
547 let stdout = child.stdout.take().expect("stdout was piped");
548 let stderr = child.stderr.take().expect("stderr was piped");
549 let stderr_task = tokio::spawn(async move {
550 let mut buf = Vec::new();
551 BufReader::new(stderr)
552 .read_to_end(&mut buf)
553 .await
554 .map(|_| buf)
555 });
556
557 let deadline = tokio::time::Instant::now() + action_timeout();
558
559 let mut lines = BufReader::new(stdout).lines();
560 let mut stdout_buf = String::new();
561 loop {
562 let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
565 Ok(Ok(line)) => line,
566 Ok(Err(err)) => {
571 return Err(
572 kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await,
573 )
574 }
575 Err(_) => {
576 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
577 }
578 };
579 let Some(line) = next else { break };
580 if !line.is_empty() {
581 let _ = progress.send(line.clone());
582 }
583 stdout_buf.push_str(&line);
584 stdout_buf.push('\n');
585 }
586
587 let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
588 Ok(Ok(status)) => status,
589 Ok(Err(err)) => {
592 return Err(kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await)
593 }
594 Err(_) => return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await),
595 };
596 let stderr_bytes = stderr_task
597 .await
598 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
599 .map_err(ExecutorError::Io)?;
600
601 Ok(ExecutionOutput {
602 stdout: stdout_buf,
603 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
604 exit_code: exit_status.code().unwrap_or(-1),
605 })
606}
607
608pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
613 match action_name {
614 "GetSystemState" => Ok(deployment::get_system_state()),
616 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
617 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
618 "ListDeployments" => Ok(deployment::list_deployments()),
619 "UpdateSystem" => Ok(deployment::update_system()),
620 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
621 "RebootSystem" => Ok(deployment::reboot_system()),
622 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
623 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
624
625 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
627 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
628 "RebaseSystem" => {
629 let target_ref = require_str(params, "target_ref")?;
630 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
631 Ok(deployment::rebase_system(&target_ref))
632 }
633 "SetKernelArguments" => {
634 let add = str_array_or_empty(params, "add")?;
635 let remove = str_array_or_empty(params, "remove")?;
636 for arg in add.iter() {
639 validated_safe_kernel_arg(arg, "add")?;
640 }
641 for arg in remove.iter() {
644 validated_safe_kernel_arg_removal(arg, "remove")?;
645 }
646 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
647 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
648 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
649 }
650
651 "ListFlatpakRemotes" => {
657 let username = validated_username(resolve_username(params)?, "username")?;
658 Ok(flatpak::list_flatpak_remotes(&username))
659 }
660 "InstallFlatpak" => {
661 let username = validated_username(resolve_username(params)?, "username")?;
662 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
663 let remote = params
667 .get("remote")
668 .and_then(|v| v.as_str())
669 .unwrap_or("flathub");
670 let remote = validated_safe_arg(remote, "remote")?;
671 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
672 }
673 "RemoveFlatpak" => {
674 let username = validated_username(resolve_username(params)?, "username")?;
675 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
676 Ok(flatpak::remove_flatpak(&username, &app_id))
677 }
678 "SearchFlatpakApps" => {
679 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
680 Ok(flatpak::search_flatpak_apps(&term))
681 }
682 "AddFlatpakRemote" => {
683 let username = validated_username(resolve_username(params)?, "username")?;
684 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
685 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
686 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
687 }
688 "RemoveFlatpakRemote" => {
689 let username = validated_username(resolve_username(params)?, "username")?;
690 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
691 Ok(flatpak::remove_flatpak_remote(&username, &remote))
692 }
693 "GetFlatpakAppInfo" => {
694 let username = validated_username(resolve_username(params)?, "username")?;
695 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
696 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
697 }
698 "ListInstalledFlatpaks" => {
699 let username = validated_username(resolve_username(params)?, "username")?;
700 Ok(flatpak::list_installed_flatpaks(&username))
701 }
702 "UpdateFlatpak" => {
703 let username = validated_username(resolve_username(params)?, "username")?;
704 let app_id = params
707 .get("app_id")
708 .and_then(|v| v.as_str())
709 .filter(|id| !id.is_empty())
710 .map(|id| validated_safe_arg(id, "app_id"))
711 .transpose()?;
712 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
713 }
714
715 "ListContainers" => {
721 let username = validated_username(resolve_username(params)?, "username")?;
722 Ok(containers::list_containers(&username))
723 }
724 "CreateContainer" => {
725 let username = validated_username(resolve_username(params)?, "username")?;
726 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
727 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
728 Ok(containers::create_container(&username, &name, &image))
729 }
730 "StartContainer" => {
731 let username = validated_username(resolve_username(params)?, "username")?;
732 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
733 Ok(containers::start_container(&username, &name))
734 }
735 "StopContainer" => {
736 let username = validated_username(resolve_username(params)?, "username")?;
737 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
738 Ok(containers::stop_container(&username, &name))
739 }
740 "RemoveContainer" => {
741 let username = validated_username(resolve_username(params)?, "username")?;
742 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
743 Ok(containers::remove_container(&username, &name))
744 }
745 "GetContainerInfo" => {
746 let username = validated_username(resolve_username(params)?, "username")?;
747 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
748 Ok(containers::get_container_info(&username, &name))
749 }
750
751 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
753 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
754 "InstallPackages" => {
755 let pkgs = str_array_or_empty(params, "packages")?;
756 let validated: Vec<String> = pkgs
757 .iter()
758 .map(|p| validated_install_package(p, "packages"))
759 .collect::<Result<_, _>>()?;
760 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
761 Ok(layering::install_packages(&refs))
762 }
763 "RemovePackages" => {
764 let pkgs = str_array_or_empty(params, "packages")?;
765 let validated: Vec<String> = pkgs
766 .iter()
767 .map(|p| validated_install_package(p, "packages"))
768 .collect::<Result<_, _>>()?;
769 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
770 Ok(layering::remove_packages(&refs))
771 }
772 "AddLayeredPackage" => {
773 let package = validated_install_package(require_str(params, "package")?, "package")?;
774 Ok(layering::add_layered_package(&package))
775 }
776 "RemoveLayeredPackage" => {
777 let package = validated_install_package(require_str(params, "package")?, "package")?;
778 Ok(layering::remove_layered_package(&package))
779 }
780 "ReplaceLayeredPackage" => {
781 let old = validated_install_package(require_str(params, "old")?, "old")?;
782 let new = validated_install_package(require_str(params, "new")?, "new")?;
783 Ok(layering::replace_layered_package(&old, &new))
784 }
785 "RemoveBasePackage" => {
786 let package = validated_install_package(require_str(params, "package")?, "package")?;
787 Ok(layering::remove_base_package(&package))
788 }
789 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
790
791 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
793 "AddPackageRepository" => Ok(package_repos::add_package_repository(
794 validated_repo_id(params)?,
795 validated_no_newline(params, "repo_url")?,
796 )),
797 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
798 validated_repo_id(params)?,
799 )),
800 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
801 validated_repo_id(params)?,
802 )),
803 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
804 validated_repo_id(params)?,
805 )),
806
807 "ListServices" => Ok(services::list_services()),
809 "StartService" => {
810 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
811 Ok(services::start_service(&unit))
812 }
813 "StopService" => {
814 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
815 Ok(services::stop_service(&unit))
816 }
817 "RestartService" => {
818 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
819 Ok(services::restart_service(&unit))
820 }
821 "SetServiceEnabled" => {
822 let enabled = require_bool(params, "enabled")?;
823 let unit = if enabled {
826 validated_activatable_unit(require_str(params, "unit")?, "unit")?
827 } else {
828 validated_unit_name(require_str(params, "unit")?, "unit")?
829 };
830 Ok(services::set_service_enabled(&unit, enabled))
831 }
832 "MaskService" => {
833 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
834 Ok(services::mask_service(&unit))
835 }
836 "UnmaskService" => {
837 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
838 Ok(services::unmask_service(&unit))
839 }
840 "GetServiceLogs" => {
841 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
842 Ok(services::get_service_logs(&unit))
843 }
844 "GetServiceStatus" => {
845 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
846 Ok(services::get_service_status(&unit))
847 }
848 "ReloadService" => {
849 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
850 Ok(services::reload_service(&unit))
851 }
852 "ListTimers" => Ok(services::list_timers()),
853 "ReloadDaemon" => Ok(services::reload_daemon()),
854 "CreateScheduledJob" => {
855 let name = require_str(params, "name")?;
858 if services::scheduled_job_unit_paths(name).is_none() {
859 return Err(ExecutorError::InvalidParam("name"));
860 }
861 let command = require_str(params, "command")?;
864 if command.is_empty()
865 || command.len() > MAX_SCHEDULED_COMMAND_LEN
866 || command.chars().any(|c| c.is_control())
867 {
868 return Err(ExecutorError::InvalidParam("command"));
869 }
870 let schedule = require_str(params, "schedule")?;
873 if schedule.is_empty()
874 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
875 || !schedule.chars().all(|c| {
876 c.is_ascii_alphanumeric()
877 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
878 })
879 {
880 return Err(ExecutorError::InvalidParam("schedule"));
881 }
882 Ok(services::create_scheduled_job(name, command, schedule))
883 }
884 "GetServiceResourceLimits" => {
885 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
886 Ok(services::get_service_resource_limits(&unit))
887 }
888 "SetServiceResourceLimits" => {
889 let unit = validated_resource_limit_unit(require_str(params, "unit")?, "unit")?;
893 let mut assignments = Vec::new();
896 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
897 assignments.push(format!("MemoryMax={v}"));
898 }
899 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
900 assignments.push(format!("MemoryHigh={v}"));
901 }
902 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
903 assignments.push(format!("CPUQuota={v}"));
904 }
905 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
906 assignments.push(format!("TasksMax={v}"));
907 }
908 if assignments.is_empty() {
909 return Err(ExecutorError::MissingParam("memory_max"));
910 }
911 Ok(services::set_service_resource_limits(&unit, &assignments))
912 }
913
914 "ListToolboxes" => {
918 let username = validated_username(resolve_username(params)?, "username")?;
919 Ok(toolbox::list_toolboxes(&username))
920 }
921 "CreateToolbox" => {
922 let username = validated_username(resolve_username(params)?, "username")?;
923 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
924 let image = params
925 .get("image")
926 .and_then(|v| v.as_str())
927 .map(|img| validated_safe_arg(img, "image"))
928 .transpose()?;
929 let release = params
930 .get("release")
931 .and_then(|v| v.as_str())
932 .map(|r| validated_safe_arg(r, "release"))
933 .transpose()?;
934 Ok(toolbox::create_toolbox(
935 &username,
936 &name,
937 release.as_deref(),
938 image.as_deref(),
939 ))
940 }
941 "RemoveToolbox" => {
942 let username = validated_username(resolve_username(params)?, "username")?;
943 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
944 Ok(toolbox::remove_toolbox(&username, &name))
945 }
946
947 "GetDateTime" => Ok(identity::get_datetime()),
949 "SetHostname" => {
950 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
951 Ok(identity::set_hostname(&hostname))
952 }
953 "SetTimezone" => {
954 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
955 Ok(identity::set_timezone(&timezone))
956 }
957 "SetLocale" => {
958 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
959 Ok(identity::set_locale(&locale))
960 }
961 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
962
963 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
965
966 "ListProcesses" => Ok(processes::list_processes_spec()),
968 "SignalProcess" => {
969 let pid = params
971 .get("pid")
972 .and_then(|v| {
973 v.as_u64()
974 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
975 })
976 .ok_or(ExecutorError::MissingParam("pid"))?;
977 if pid < 2 || pid > u32::MAX as u64 {
980 return Err(ExecutorError::InvalidParam("pid"));
981 }
982 let signal = validated_kill_signal(
983 params
984 .get("signal")
985 .and_then(|v| v.as_str())
986 .unwrap_or("TERM"),
987 )?;
988 Ok(processes::signal_process(pid as u32, signal))
989 }
990
991 "GetJournalLog" => {
993 let unit = optional_validated(params, "unit", validated_unit_name)?;
994 let priority = optional_validated(params, "priority", validated_journal_priority)?;
995 let since = optional_validated(params, "since", validated_journal_time)?;
996 let until = optional_validated(params, "until", validated_journal_time)?;
997 let grep = optional_validated(params, "grep", validated_journal_grep)?;
998 let lines = params
1001 .get("lines")
1002 .and_then(|v| v.as_u64())
1003 .unwrap_or(100)
1004 .clamp(1, MAX_JOURNAL_LINES) as u32;
1005 let boot = params
1006 .get("boot")
1007 .and_then(|v| v.as_bool())
1008 .unwrap_or(false);
1009 let kernel = params
1010 .get("kernel")
1011 .and_then(|v| v.as_bool())
1012 .unwrap_or(false);
1013 Ok(journald::get_journal_log(&journald::JournalQuery {
1014 lines,
1015 unit: unit.as_deref(),
1016 priority: priority.as_deref(),
1017 boot,
1018 kernel,
1019 since: since.as_deref(),
1020 until: until.as_deref(),
1021 grep: grep.as_deref(),
1022 }))
1023 }
1024 "VacuumJournal" => {
1025 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1027 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1028 match (size_mb, retain_days) {
1029 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1030 Ok(journald::vacuum_journal_by_size(mb as u32))
1031 }
1032 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1033 Ok(journald::vacuum_journal_by_time(days as u32))
1034 }
1035 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1036 _ => Err(ExecutorError::InvalidParam("size_mb")),
1038 }
1039 }
1040
1041 "GetLvmReport" => Ok(lvm::get_lvm_report()),
1043 "ExtendLogicalVolume" => {
1044 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1045 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1046 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1047 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1048 }
1049 "CreateLogicalVolume" => {
1050 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1051 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1052 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1053 Ok(lvm::create_logical_volume(&vg, &name, &size))
1054 }
1055 "CreateLvSnapshot" => {
1056 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1057 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1058 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1059 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1060 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1061 }
1062
1063 "GetSysctl" => {
1065 let key = optional_validated(params, "key", validated_sysctl_key)?;
1067 Ok(sysctl::get_sysctl(key.as_deref()))
1068 }
1069 "SetSysctl" => {
1070 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1071 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1072 Ok(sysctl::set_sysctl(&key, &value))
1073 }
1074
1075 "GetMounts" => Ok(mounts::get_mounts()),
1077 "AddMount" => {
1078 let device = validated_mount_device(require_str(params, "device")?, "device")?;
1079 let mountpoint =
1080 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1081 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1082 let options = optional_validated(params, "options", validated_mount_options)?;
1083 Ok(mounts::add_mount(
1084 &device,
1085 &mountpoint,
1086 &fstype,
1087 options.as_deref(),
1088 ))
1089 }
1090 "RemoveMount" => {
1091 let mountpoint =
1092 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1093 Ok(mounts::remove_mount(&mountpoint))
1094 }
1095 "AddSwap" => {
1096 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1097 let size_mb = require_u32(params, "size_mb")?;
1098 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1100 return Err(ExecutorError::InvalidParam("size_mb"));
1101 }
1102 Ok(mounts::add_swap(&file, size_mb))
1103 }
1104 "RemoveSwap" => {
1105 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1106 Ok(mounts::remove_swap(&file))
1107 }
1108
1109 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1111 "GrantSudoAccess" => {
1112 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1113 let user = validated_username(require_str(params, "user")?, "user")?;
1114 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1115 let runas = match params
1117 .get("runas")
1118 .and_then(|v| v.as_str())
1119 .filter(|s| !s.is_empty())
1120 {
1121 Some("ALL") => Some("ALL".to_string()),
1122 Some(u) => Some(validated_username(u, "runas")?),
1123 None => None,
1124 };
1125 let nopasswd = params
1126 .get("nopasswd")
1127 .and_then(|v| v.as_bool())
1128 .unwrap_or(false);
1129 let shell_equivalent = shell_equivalent_sudo_command(&commands);
1148 if (commands == "ALL" || shell_equivalent.is_some()) && nopasswd {
1149 return Err(ExecutorError::InvalidParam("commands"));
1150 }
1151 Ok(sudoers::grant_sudo_access(
1152 &name,
1153 &user,
1154 &commands,
1155 runas.as_deref(),
1156 nopasswd,
1157 ))
1158 }
1159 "RevokeSudoAccess" => {
1160 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1161 Ok(sudoers::revoke_sudo_access(&name))
1162 }
1163
1164 "GetAptPins" => {
1166 let package = optional_validated(params, "package", validated_apt_package)?;
1167 Ok(apt_preferences::get_apt_pins(package.as_deref()))
1168 }
1169 "SetAptPin" => {
1170 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1171 let package = validated_apt_package(require_str(params, "package")?, "package")?;
1172 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1173 let priority = params
1174 .get("priority")
1175 .and_then(|v| v.as_i64())
1176 .ok_or(ExecutorError::MissingParam("priority"))?;
1177 if !(-1..=1000).contains(&priority) {
1178 return Err(ExecutorError::InvalidParam("priority"));
1179 }
1180 Ok(apt_preferences::set_apt_pin(
1181 &name, &package, &pin, priority,
1182 ))
1183 }
1184 "RemoveAptPin" => {
1185 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1186 Ok(apt_preferences::remove_apt_pin(&name))
1187 }
1188
1189 "GetLogrotateStatus" => {
1191 let config = optional_validated(params, "config", validated_log_path)?;
1192 Ok(logging::get_logrotate_status(config.as_deref()))
1193 }
1194 "ConfigureLogRotation" => {
1195 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1196 let path = validated_log_path(require_str(params, "path")?, "path")?;
1197 let frequency = require_str(params, "frequency")?;
1198 if !matches!(frequency, "daily" | "weekly" | "monthly") {
1199 return Err(ExecutorError::InvalidParam("frequency"));
1200 }
1201 let rotate = require_u32(params, "rotate")?;
1202 if rotate > 1000 {
1203 return Err(ExecutorError::InvalidParam("rotate"));
1204 }
1205 let compress = params
1206 .get("compress")
1207 .and_then(|v| v.as_bool())
1208 .unwrap_or(false);
1209 Ok(logging::configure_log_rotation(
1210 &name, &path, frequency, rotate, compress,
1211 ))
1212 }
1213 "RemoveLogRotation" => {
1214 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1215 Ok(logging::remove_log_rotation(&name))
1216 }
1217 "ConfigureRemoteSyslog" => {
1218 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1219 let port = require_u32(params, "port")?;
1220 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1221 return Err(ExecutorError::InvalidParam("port"));
1222 }
1223 let protocol = require_str(params, "protocol")?;
1224 if !matches!(protocol, "tcp" | "udp") {
1225 return Err(ExecutorError::InvalidParam("protocol"));
1226 }
1227 Ok(logging::configure_remote_syslog(
1228 &host,
1229 port as u16,
1230 protocol,
1231 ))
1232 }
1233 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1234
1235 "GetPasswordAging" => {
1237 let user = validated_username(require_str(params, "user")?, "user")?;
1238 Ok(pam::get_password_aging(&user))
1239 }
1240 "SetPasswordAging" => {
1241 let user = validated_username(require_str(params, "user")?, "user")?;
1242 let mut flags = Vec::new();
1244 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1245 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1246 if n > MAX_PASSWORD_AGE_DAYS {
1247 return Err(ExecutorError::InvalidParam(key));
1248 }
1249 flags.push(opt.to_string());
1250 flags.push(n.to_string());
1251 }
1252 }
1253 if flags.is_empty() {
1254 return Err(ExecutorError::MissingParam("max_days"));
1255 }
1256 Ok(pam::set_password_aging(&user, &flags))
1257 }
1258 "SetPasswordPolicy" => {
1259 let mut extra = Vec::new();
1262 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1263 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1264 return Err(ExecutorError::InvalidParam("minlen"));
1265 }
1266 extra.push("--minlen".to_string());
1267 extra.push(n.to_string());
1268 }
1269 for (key, flag) in [
1270 ("dcredit", "--dcredit"),
1271 ("ucredit", "--ucredit"),
1272 ("lcredit", "--lcredit"),
1273 ("ocredit", "--ocredit"),
1274 ] {
1275 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1276 if !(-64..=64).contains(&n) {
1277 return Err(ExecutorError::InvalidParam(key));
1278 }
1279 extra.push(flag.to_string());
1280 extra.push(n.to_string());
1281 }
1282 }
1283 if extra.is_empty() {
1284 return Err(ExecutorError::MissingParam("minlen"));
1285 }
1286 Ok(pam::set_password_policy(&extra))
1287 }
1288 "SetAccountLockout" => {
1289 let mut extra = Vec::new();
1291 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1292 if !(1..=1000).contains(&n) {
1293 return Err(ExecutorError::InvalidParam("deny"));
1294 }
1295 extra.push("--deny".to_string());
1296 extra.push(n.to_string());
1297 }
1298 for (key, flag) in [
1299 ("unlock_time", "--unlock-time"),
1300 ("fail_interval", "--fail-interval"),
1301 ] {
1302 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1303 if n > MAX_LOCKOUT_WINDOW_SECS {
1304 return Err(ExecutorError::InvalidParam(key));
1305 }
1306 extra.push(flag.to_string());
1307 extra.push(n.to_string());
1308 }
1309 }
1310 if extra.is_empty() {
1311 return Err(ExecutorError::MissingParam("deny"));
1312 }
1313 Ok(pam::set_account_lockout(&extra))
1314 }
1315
1316 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1318 "GetHostState" => Ok(system_info::get_host_state()),
1319
1320 "GetFirewallState" => Ok(network::get_firewall_state()),
1322 "GetNetworkStatus" => Ok(network::get_network_status()),
1323 "GetListeningPorts" => Ok(network::get_listening_ports()),
1324 "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1325 "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1326 "ConfigureWifi" => {
1327 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1328 let password = params
1330 .get("password")
1331 .and_then(|v| v.as_str())
1332 .filter(|p| !p.is_empty())
1333 .map(|p| validated_safe_arg(p, "password"))
1334 .transpose()?;
1335 Ok(network::configure_wifi(&ssid, password.as_deref()))
1336 }
1337 "SetDnsServers" => {
1338 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1339 let servers = str_array_or_empty(params, "servers")?;
1340 let validated: Vec<String> = servers
1341 .iter()
1342 .map(|s| validated_safe_arg(s, "servers"))
1343 .collect::<Result<_, _>>()?;
1344 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1345 Ok(network::set_dns_servers(&interface, &refs))
1346 }
1347 "ConfigureFirewall" => {
1348 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1349 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1350 Ok(network::configure_firewall(
1351 &zone,
1352 &service,
1353 require_bool(params, "enabled")?,
1354 ))
1355 }
1356
1357 "ListUsers" => Ok(users::list_users()),
1359 "ListGroups" => Ok(users::list_groups()),
1360 "CreateUser" => {
1361 let username = validated_username(resolve_username(params)?, "username")?;
1362 let shell = params
1363 .get("shell")
1364 .and_then(|v| v.as_str())
1365 .map(|s| validated_safe_arg(s, "shell"))
1366 .transpose()?;
1367 let home = params
1368 .get("home")
1369 .and_then(|v| v.as_str())
1370 .map(|h| validated_home_dir(h, "home"))
1371 .transpose()?;
1372 Ok(users::create_user(
1373 &username,
1374 shell.as_deref(),
1375 home.as_deref(),
1376 ))
1377 }
1378 "DeleteUser" => {
1379 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1380 Ok(users::delete_user(&username))
1381 }
1382 "AddUserToGroup" => {
1383 let username = validated_username(resolve_username(params)?, "username")?;
1384 let group = validated_group(require_str(params, "group")?, "group")?;
1385 Ok(users::add_user_to_group(&username, &group))
1386 }
1387 "RemoveUserFromGroup" => {
1388 let username = validated_username(resolve_username(params)?, "username")?;
1389 let group = validated_group(require_str(params, "group")?, "group")?;
1390 Ok(users::remove_user_from_group(&username, &group))
1391 }
1392 "CreateGroup" => {
1393 let group = validated_group(require_str(params, "group")?, "group")?;
1394 let system = params
1395 .get("system")
1396 .and_then(|v| v.as_bool())
1397 .unwrap_or(false);
1398 Ok(users::create_group(&group, system))
1399 }
1400 "DeleteGroup" => {
1401 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1402 Ok(users::delete_group(&group))
1403 }
1404 "LockUserAccount" => {
1405 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1406 Ok(users::lock_user_account(&username))
1407 }
1408 "UnlockUserAccount" => {
1409 let username = validated_username(resolve_username(params)?, "username")?;
1410 Ok(users::unlock_user_account(&username))
1411 }
1412
1413 "GetAuthorizedKeys" => {
1415 let username = validated_username(resolve_username(params)?, "username")?;
1416 Ok(ssh::get_authorized_keys(&username))
1417 }
1418 "AddAuthorizedKey" => {
1419 let username = validated_username(resolve_username(params)?, "username")?;
1420 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1421 Ok(ssh::add_authorized_key(&username, &public_key))
1422 }
1423 "RemoveAuthorizedKey" => {
1424 let username = validated_username(resolve_username(params)?, "username")?;
1425 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1426 Ok(ssh::remove_authorized_key(&username, &public_key))
1427 }
1428 "SetSshdOption" => {
1429 let option = require_str(params, "option")?;
1433 let value = require_str(params, "value")?;
1434 let allowed_values: &[&str] = match option {
1435 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1436 "PasswordAuthentication"
1437 | "PubkeyAuthentication"
1438 | "X11Forwarding"
1439 | "PermitEmptyPasswords" => &["yes", "no"],
1440 _ => return Err(ExecutorError::InvalidParam("option")),
1441 };
1442 if !allowed_values.contains(&value) {
1443 return Err(ExecutorError::InvalidParam("value"));
1444 }
1445 Ok(ssh::set_sshd_option(option, value))
1446 }
1447
1448 "AptUpdate" => Ok(apt::apt_update()),
1450 "AptUpgrade" => Ok(apt::apt_upgrade()),
1451 "AptInstall" => {
1452 let package = validated_install_package(require_str(params, "package")?, "package")?;
1453 Ok(apt::apt_install(&package))
1454 }
1455 "AptRemove" => {
1456 let package = validated_install_package(require_str(params, "package")?, "package")?;
1457 Ok(apt::apt_remove(&package))
1458 }
1459 "AptPurge" => {
1460 let package = validated_install_package(require_str(params, "package")?, "package")?;
1461 Ok(apt::apt_purge(&package))
1462 }
1463 "AptAutoremove" => Ok(apt::apt_autoremove()),
1464 "AptHold" => {
1465 let package = validated_install_package(require_str(params, "package")?, "package")?;
1466 Ok(apt::apt_hold(&package))
1467 }
1468 "AptUnhold" => {
1469 let package = validated_install_package(require_str(params, "package")?, "package")?;
1470 Ok(apt::apt_unhold(&package))
1471 }
1472 "AptSearch" => {
1473 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1474 Ok(apt::apt_search(&term))
1475 }
1476 "AptListInstalled" => Ok(apt::apt_list_installed()),
1477 "AptShow" => {
1478 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1479 Ok(apt::apt_show(&package))
1480 }
1481 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1482 "AptHistoryList" => Ok(apt::apt_history_list()),
1483 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1484 params, "enabled",
1485 )?)),
1486
1487 "AddPpa" => {
1489 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1490 Ok(ppa::add_ppa(&name))
1491 }
1492 "RemovePpa" => {
1493 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1494 Ok(ppa::remove_ppa(&name))
1495 }
1496
1497 "SnapInstall" => {
1499 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1500 let channel = params
1501 .get("channel")
1502 .and_then(|v| v.as_str())
1503 .filter(|s| !s.is_empty())
1504 .map(|s| validated_safe_arg(s, "channel"))
1505 .transpose()?;
1506 let auto_update = params
1507 .get("auto_update")
1508 .and_then(|v| v.as_bool())
1509 .unwrap_or(false);
1510 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1511 snap::SnapInstallError::InvalidArg { param, .. } => {
1512 ExecutorError::InvalidParam(param)
1513 }
1514 })
1515 }
1516 "SnapRemove" => {
1517 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1518 Ok(snap::snap_remove(&name))
1519 }
1520 "SnapRefresh" => {
1521 let name = params
1522 .get("name")
1523 .and_then(|v| v.as_str())
1524 .filter(|s| !s.is_empty())
1525 .map(|s| validated_safe_arg(s, "name"))
1526 .transpose()?;
1527 Ok(snap::snap_refresh(name.as_deref()))
1528 }
1529 "SnapHold" => {
1530 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1531 Ok(snap::snap_hold(&name))
1532 }
1533 "SnapUnhold" => {
1534 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1535 Ok(snap::snap_unhold(&name))
1536 }
1537 "SnapList" => Ok(snap::snap_list()),
1538 "SnapInfo" => {
1539 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1540 Ok(snap::snap_info(&name))
1541 }
1542 "SnapRevert" => {
1543 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1544 Ok(snap::snap_revert(&name))
1545 }
1546 "SnapClassicInstall" => {
1547 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1548 Ok(snap::snap_classic_install(&name))
1549 }
1550
1551 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1553 "GrubSetKargs" => {
1554 let append: Vec<String> = params
1555 .get("append")
1556 .and_then(|v| v.as_array())
1557 .map(|arr| {
1558 arr.iter()
1559 .filter_map(|v| v.as_str())
1560 .map(String::from)
1561 .collect()
1562 })
1563 .unwrap_or_default();
1564 let delete: Vec<String> = params
1565 .get("delete")
1566 .and_then(|v| v.as_array())
1567 .map(|arr| {
1568 arr.iter()
1569 .filter_map(|v| v.as_str())
1570 .map(String::from)
1571 .collect()
1572 })
1573 .unwrap_or_default();
1574 for a in &append {
1583 validated_safe_arg(a, "append")?;
1584 validated_safe_kernel_arg(a, "append")?;
1585 }
1586 for d in &delete {
1587 validated_safe_arg(d, "delete")?;
1588 validated_safe_kernel_arg_removal(d, "delete")?;
1589 }
1590 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1593 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1594 grub::grub_set_kargs(&append_refs, &delete_refs)
1595 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1596 }
1597
1598 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1600
1601 "UfwEnable" => Ok(ufw::ufw_enable()),
1603 "UfwDisable" => Ok(ufw::ufw_disable()),
1604 "UfwAllow" => {
1605 let port_or_service = validated_port_or_service(
1606 require_str(params, "port_or_service")?,
1607 "port_or_service",
1608 )?;
1609 Ok(ufw::ufw_allow(&port_or_service))
1610 }
1611 "UfwDeny" => {
1612 let port_or_service = validated_port_or_service(
1613 require_str(params, "port_or_service")?,
1614 "port_or_service",
1615 )?;
1616 Ok(ufw::ufw_deny(&port_or_service))
1617 }
1618 "UfwReset" => Ok(ufw::ufw_reset()),
1619 "UfwStatus" => {
1620 let numbered = match params.get("numbered") {
1621 None => false,
1622 Some(value) => value
1623 .as_bool()
1624 .ok_or(ExecutorError::InvalidParam("numbered"))?,
1625 };
1626 Ok(ufw::ufw_status_with_numbered(numbered))
1627 }
1628
1629 "DistroboxList" => Ok(distrobox::distrobox_list()),
1631 "DistroboxCreate" => {
1632 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1633 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1634 Ok(distrobox::distrobox_create(&name, &image))
1635 }
1636 "DistroboxRemove" => {
1637 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1638 Ok(distrobox::distrobox_remove(&name))
1639 }
1640
1641 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1643 "NetplanApply" => Ok(netplan::netplan_apply()),
1644 "NetplanSet" => {
1645 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1646 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1647 Ok(netplan::netplan_set(&key, &value))
1648 }
1649 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1650
1651 "UfwDeleteRule" => {
1653 let rule_number = require_positive_u32(params, "rule_number")?;
1654 ufw::ufw_delete_rule(rule_number)
1655 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1656 }
1657 "UfwLimit" => {
1658 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1659 Ok(ufw::ufw_limit(&target))
1660 }
1661
1662 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1664 "ProAttach" => {
1665 let token = require_str(params, "token")?;
1667 let token = validated_safe_arg(token, "token")?;
1669 Ok(ubuntu_pro::pro_attach(&token))
1670 }
1671 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1672 "EnableProService" => {
1673 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1674 Ok(ubuntu_pro::enable_pro_service(&service))
1675 }
1676 "DisableProService" => {
1677 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1678 Ok(ubuntu_pro::disable_pro_service(&service))
1679 }
1680
1681 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1683
1684 "MultipassList" => Ok(multipass::multipass_list()),
1686
1687 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1689
1690 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1692 "ResolvectlSetDns" => {
1693 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1694 let raw_servers: Vec<String> = params
1695 .get("servers")
1696 .and_then(|v| v.as_array())
1697 .map(|arr| {
1698 arr.iter()
1699 .filter_map(|v| v.as_str())
1700 .map(String::from)
1701 .collect()
1702 })
1703 .unwrap_or_default();
1704 if raw_servers.is_empty() {
1705 return Err(ExecutorError::MissingParam("servers"));
1706 }
1707 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1711 for s in &raw_servers {
1712 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1713 param: "servers",
1714 value: s.clone(),
1715 })?;
1716 parsed_servers.push(addr);
1717 }
1718 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1719 }
1720
1721 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1723 "AppArmorEnforce" => {
1724 let profile_path =
1725 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1726 Ok(apparmor::apparmor_enforce(&profile_path))
1727 }
1728 "AppArmorComplain" => {
1729 let profile_path =
1730 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1731 Ok(apparmor::apparmor_complain(&profile_path))
1732 }
1733
1734 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1736
1737 "UbuntuInstallFlatpak" => {
1739 let username = validated_username(resolve_username(params)?, "username")?;
1740 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1741 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1742 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1743 }
1744 "UbuntuRemoveFlatpak" => {
1745 let username = validated_username(resolve_username(params)?, "username")?;
1746 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1747 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1748 }
1749 "UbuntuUpdateFlatpak" => {
1750 let username = validated_username(resolve_username(params)?, "username")?;
1751 let app_id = params
1752 .get("app_id")
1753 .and_then(|v| v.as_str())
1754 .filter(|s| !s.is_empty())
1755 .map(|s| validated_safe_arg(s, "app_id"))
1756 .transpose()?;
1757 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1758 }
1759 "UbuntuListFlatpaks" => {
1760 let username = validated_username(resolve_username(params)?, "username")?;
1761 Ok(flatpak::ubuntu_list_flatpaks(&username))
1762 }
1763
1764 "Fail2banStatus" => {
1766 let jail = params
1767 .get("jail")
1768 .and_then(|v| v.as_str())
1769 .filter(|s| !s.is_empty())
1770 .map(|s| validated_safe_arg(s, "jail"))
1771 .transpose()?;
1772 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1773 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1774 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1775 })
1776 }
1777 "Fail2banBanIp" => {
1778 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1779 let ip = require_str(params, "ip")?;
1780 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1781 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1782 param: "ip",
1783 value: v,
1784 },
1785 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1786 })
1787 }
1788 "Fail2banUnbanIp" => {
1789 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1790 let ip = require_str(params, "ip")?;
1791 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1792 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1793 param: "ip",
1794 value: v,
1795 },
1796 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1797 })
1798 }
1799 "ConfigureFail2banJail" => {
1800 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1801 let mut extra = Vec::new();
1802 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1803 extra.push("--enabled".to_string());
1804 extra.push(if n { "true" } else { "false" }.to_string());
1805 }
1806 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1807 if !(1..=100).contains(&n) {
1808 return Err(ExecutorError::InvalidParam("maxretry"));
1809 }
1810 extra.push("--maxretry".to_string());
1811 extra.push(n.to_string());
1812 }
1813 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1814 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1815 if n > MAX_FAIL2BAN_WINDOW_SECS {
1816 return Err(ExecutorError::InvalidParam(key));
1817 }
1818 extra.push(flag.to_string());
1819 extra.push(n.to_string());
1820 }
1821 }
1822 if extra.is_empty() {
1823 return Err(ExecutorError::MissingParam("maxretry"));
1824 }
1825 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
1826 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
1827 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
1828 })
1829 }
1830
1831 "GetAuditRules" => Ok(auditd::get_audit_rules()),
1833 "AddAuditRule" => {
1834 let path = validated_audit_path(require_str(params, "path")?, "path")?;
1835 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1836 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1837 Ok(auditd::add_audit_rule(&path, &perms, &key))
1838 }
1839 "RemoveAuditRule" => {
1840 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1841 Ok(auditd::remove_audit_rule(&key))
1842 }
1843
1844 "GetCertificates" => Ok(certbot::get_certificates()),
1846 "ObtainCertificate" => {
1847 let mut domains = Vec::new();
1849 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1850 for d in arr {
1851 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1852 domains.push(validated_domain(s, "domains")?);
1853 }
1854 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1855 domains.push(validated_domain(d, "domain")?);
1856 }
1857 if domains.is_empty() {
1858 return Err(ExecutorError::MissingParam("domains"));
1859 }
1860 let email = validated_email(require_str(params, "email")?, "email")?;
1861 let challenge = params
1862 .get("challenge")
1863 .and_then(|v| v.as_str())
1864 .unwrap_or("standalone");
1865 if !matches!(challenge, "standalone" | "nginx" | "apache") {
1866 return Err(ExecutorError::InvalidParam("challenge"));
1867 }
1868 Ok(certbot::obtain_certificate(&domains, &email, challenge))
1869 }
1870 "RenewCertificates" => Ok(certbot::renew_certificates()),
1871
1872 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1873 }
1874}
1875
1876pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1882 match &spec.mechanism {
1883 ActionMechanism::Command { program, args } => {
1884 let mut child = tokio::process::Command::new(program)
1888 .args(args)
1889 .stdin(Stdio::null())
1890 .process_group(0)
1893 .stdout(Stdio::piped())
1894 .stderr(Stdio::piped())
1895 .spawn()?;
1896
1897 let stdout_h = child.stdout.take().expect("stdout was piped");
1902 let stderr_h = child.stderr.take().expect("stderr was piped");
1903 let stdout_task = tokio::spawn(async move {
1904 let mut buf = Vec::new();
1905 BufReader::new(stdout_h)
1906 .read_to_end(&mut buf)
1907 .await
1908 .map(|_| buf)
1909 });
1910 let stderr_task = tokio::spawn(async move {
1911 let mut buf = Vec::new();
1912 BufReader::new(stderr_h)
1913 .read_to_end(&mut buf)
1914 .await
1915 .map(|_| buf)
1916 });
1917
1918 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
1919 Ok(Ok(status)) => status,
1920 Ok(Err(err)) => {
1924 return Err(kill_and_reap(
1925 &mut child,
1926 program,
1927 args,
1928 StopCause::Unreadable(err),
1929 )
1930 .await)
1931 }
1932 Err(_) => {
1933 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
1934 }
1935 };
1936 let join =
1937 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
1938 let stdout = stdout_task.await.map_err(join)??;
1939 let stderr = stderr_task.await.map_err(join)??;
1940
1941 Ok(ExecutionOutput {
1942 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1943 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1944 exit_code: status.code().unwrap_or(-1),
1945 })
1946 }
1947 ActionMechanism::FileScan { path } => {
1948 let mut entries = tokio::fs::read_dir(path).await?;
1949 let mut names = Vec::new();
1950 while let Some(entry) = entries.next_entry().await? {
1951 names.push(entry.file_name().to_string_lossy().into_owned());
1952 }
1953 names.sort();
1954 Ok(ExecutionOutput {
1955 stdout: names.join("\n"),
1956 stderr: String::new(),
1957 exit_code: 0,
1958 })
1959 }
1960 ActionMechanism::FileWrite { path, content } => {
1961 if let Some(parent) = std::path::Path::new(path).parent() {
1962 tokio::fs::create_dir_all(parent).await?;
1963 }
1964 tokio::fs::write(path, content).await?;
1965 Ok(ExecutionOutput {
1966 stdout: String::new(),
1967 stderr: String::new(),
1968 exit_code: 0,
1969 })
1970 }
1971 ActionMechanism::FilePatch {
1972 path,
1973 search,
1974 replace,
1975 } => {
1976 let content = tokio::fs::read_to_string(path).await?;
1977 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1978 if patched == content && !search.is_empty() {
1979 return Ok(ExecutionOutput {
1980 stdout: String::new(),
1981 stderr: format!("search string not found in file: {}", path),
1982 exit_code: 1,
1983 });
1984 }
1985 tokio::fs::write(path, patched).await?;
1986 Ok(ExecutionOutput {
1987 stdout: String::new(),
1988 stderr: String::new(),
1989 exit_code: 0,
1990 })
1991 }
1992 ActionMechanism::FileDelete { path } => {
1993 tokio::fs::remove_file(path).await?;
1994 Ok(ExecutionOutput {
1995 stdout: String::new(),
1996 stderr: String::new(),
1997 exit_code: 0,
1998 })
1999 }
2000 }
2001}
2002
2003fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
2004 match params.get(key) {
2005 None => Err(ExecutorError::MissingParam(key)),
2006 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
2007 }
2008}
2009
2010fn optional_validated<F>(
2014 params: &Value,
2015 key: &'static str,
2016 validator: F,
2017) -> Result<Option<String>, ExecutorError>
2018where
2019 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2020{
2021 match params
2022 .get(key)
2023 .and_then(|v| v.as_str())
2024 .filter(|s| !s.is_empty())
2025 {
2026 Some(s) => Ok(Some(validator(s, key)?)),
2027 None => Ok(None),
2028 }
2029}
2030
2031fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2041 params
2042 .get("username")
2043 .or_else(|| params.get("user"))
2044 .ok_or(ExecutorError::MissingParam("username"))
2045 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2046}
2047
2048fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2052 let id = require_str(params, "repo_id")?;
2053 let valid = !id.is_empty()
2054 && id
2055 .chars()
2056 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2057 if valid {
2058 Ok(id)
2059 } else {
2060 Err(ExecutorError::InvalidParam("repo_id"))
2061 }
2062}
2063
2064fn validated_no_newline<'a>(
2067 params: &'a Value,
2068 key: &'static str,
2069) -> Result<&'a str, ExecutorError> {
2070 let val = require_str(params, key)?;
2071 if val.contains('\n') || val.contains('\r') {
2072 Err(ExecutorError::InvalidParam(key))
2073 } else {
2074 Ok(val)
2075 }
2076}
2077
2078fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2082 const MAX_LEN: usize = 8192;
2083 const ALLOWED_PREFIXES: &[&str] = &[
2084 "ssh-rsa",
2085 "ssh-ed25519",
2086 "ssh-ed25519-sk",
2087 "ecdsa-sha2-nistp256",
2088 "ecdsa-sha2-nistp384",
2089 "ecdsa-sha2-nistp521",
2090 "sk-ssh-ed25519",
2091 "sk-ecdsa-sha2-nistp256",
2092 ];
2093
2094 if s.is_empty() || s.len() > MAX_LEN {
2095 return Err(ExecutorError::InvalidParam("public_key"));
2096 }
2097 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2098 return Err(ExecutorError::InvalidParam("public_key"));
2099 }
2100 if s.chars().any(|c| {
2115 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2116 || !c.is_ascii()
2117 || c.is_ascii_control()
2118 }) {
2119 return Err(ExecutorError::InvalidParam("public_key"));
2120 }
2121 Ok(s.to_string())
2122}
2123
2124fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2125 match params.get(key) {
2126 None => Err(ExecutorError::MissingParam(key)),
2127 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2128 }
2129}
2130
2131fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2132 match params.get(key) {
2133 None => Err(ExecutorError::MissingParam(key)),
2134 Some(v) => {
2135 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2136 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2137 }
2138 }
2139}
2140
2141fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2145 let n = require_u32(params, key)?;
2146 if n == 0 {
2147 return Err(ExecutorError::InvalidParam(key));
2148 }
2149 Ok(n)
2150}
2151
2152fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2156 match params.get(key) {
2157 None | Some(Value::Null) => Ok(vec![]),
2158 Some(Value::Array(arr)) => arr
2159 .iter()
2160 .map(|v| {
2161 v.as_str()
2162 .map(String::from)
2163 .ok_or(ExecutorError::InvalidParam(key))
2164 })
2165 .collect(),
2166 _ => Err(ExecutorError::InvalidParam(key)),
2167 }
2168}
2169
2170fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2205 const BLOCKED_PREFIXES: &[&str] = &[
2206 "init=",
2207 "selinux=0",
2208 "enforcing=0",
2209 "security=",
2210 "module_blacklist=",
2211 "apparmor=0",
2212 "mitigations=off",
2213 "lockdown=",
2214 "pti=off",
2215 "systemd.debug_shell",
2224 "systemd.debug-shell",
2225 "rd.systemd.debug_shell",
2226 "rd.systemd.debug-shell",
2227 ];
2228 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2229 use crate::actions::validate::ROOT_SHELL_UNITS as BLOCKED_UNIT_PREFIXES;
2235
2236 let lower = arg.to_lowercase();
2237 let base = lower.split('=').next().unwrap_or(&lower);
2239
2240 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2241 return Err(ExecutorError::InvalidParam(param));
2242 }
2243 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2244 return Err(ExecutorError::InvalidParam(param));
2245 }
2246 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2248 if BLOCKED_UNIT_PREFIXES
2249 .iter()
2250 .any(|u| unit_val.starts_with(u))
2251 {
2252 return Err(ExecutorError::InvalidParam(param));
2253 }
2254 }
2255 if BLOCKED_EXACT.contains(&base) {
2257 return Err(ExecutorError::InvalidParam(param));
2258 }
2259 Ok(())
2260}
2261
2262fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2278 const WEAKENING: &[&str] = &[
2281 "selinux=0",
2282 "enforcing=0",
2283 "apparmor=0",
2284 "mitigations=off",
2285 "pti=off",
2286 "lockdown=none",
2287 "debugfs=on",
2288 "iommu=off",
2289 "intel_iommu=off",
2290 "module.sig_enforce=0",
2291 "init_on_alloc=0",
2292 "init_on_free=0",
2293 "randomize_kstack_offset=off",
2294 "page_alloc.shuffle=0",
2295 ];
2296 const PROTECTIVE_PREFIXES: &[&str] = &[
2297 "lockdown=",
2298 "module.sig_enforce=",
2299 "security=",
2300 "selinux=",
2301 "enforcing=",
2302 "apparmor=",
2303 "pti=",
2304 "mitigations=",
2305 "init_on_alloc=",
2306 "init_on_free=",
2307 "page_alloc.shuffle=",
2308 "randomize_kstack_offset=",
2309 "slub_debug=",
2310 "page_poison=",
2311 "vsyscall=",
2312 "debugfs=",
2313 "iommu=",
2314 "intel_iommu=",
2315 "amd_iommu=",
2316 ];
2317 const PROTECTIVE_EXACT: &[&str] = &["slab_nomerge", "nosmt", "kaslr"];
2318
2319 let lower = arg.to_lowercase();
2320 if WEAKENING.contains(&lower.as_str()) {
2321 return Ok(());
2322 }
2323 if PROTECTIVE_PREFIXES.iter().any(|p| lower.starts_with(p))
2326 || PROTECTIVE_EXACT.contains(&lower.as_str())
2327 {
2328 return Err(ExecutorError::InvalidParam(param));
2329 }
2330 Ok(())
2334}
2335
2336fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2344 let normalized = s.trim().to_ascii_uppercase();
2345 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2346 "TERM" => Ok("TERM"),
2347 "KILL" => Ok("KILL"),
2348 "HUP" => Ok("HUP"),
2349 "INT" => Ok("INT"),
2350 _ => Err(ExecutorError::InvalidParam("signal")),
2351 }
2352}
2353
2354pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2364 match action_name {
2365 "UpdateSystem"
2366 | "InstallPackages"
2367 | "RemovePackages"
2368 | "RebaseSystem"
2369 | "SetKernelArguments"
2370 | "AddLayeredPackage"
2371 | "RemoveLayeredPackage"
2372 | "ReplaceLayeredPackage"
2373 | "ResetLayeredPackageOverride"
2374 | "RemoveBasePackage" => Some(ActionSpec {
2375 action_name: "RollbackDeployment",
2376 mechanism: ActionMechanism::Command {
2377 program: "rpm-ostree",
2378 args: vec!["rollback".to_string()],
2379 },
2380 risk_level: sysknife_types::RiskLevel::High,
2381 reboot_required: true,
2382 rollback_available: false,
2383 }),
2384 _ => None,
2385 }
2386}
2387
2388#[cfg(test)]
2389mod tests {
2390 use super::*;
2391 use serde_json::json;
2392 use sysknife_types::RiskLevel;
2393 use tempfile::tempdir;
2394
2395 #[test]
2398 fn build_spec_no_params_for_get_system_state() {
2399 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2400 assert_eq!(spec.action_name, "GetSystemState");
2401 assert_eq!(spec.risk_level, RiskLevel::Low);
2402 }
2403
2404 #[test]
2405 fn build_spec_get_datetime_is_low_risk() {
2406 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2407 assert_eq!(spec.action_name, "GetDateTime");
2408 assert_eq!(spec.risk_level, RiskLevel::Low);
2409 assert!(!spec.reboot_required);
2410 }
2411
2412 #[test]
2413 fn build_spec_unknown_action_returns_error() {
2414 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2415 assert!(
2416 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2417 "expected UnknownAction, got: {err}"
2418 );
2419 }
2420
2421 #[test]
2422 fn build_spec_missing_param_for_install_flatpak() {
2423 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2425 assert!(
2426 matches!(err, ExecutorError::MissingParam("username")),
2427 "expected MissingParam(username), got: {err}"
2428 );
2429 }
2430
2431 #[test]
2435 fn build_spec_flatpak_accepts_user_alias() {
2436 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2437 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2438 }
2439
2440 #[test]
2442 fn build_spec_resolve_username_prefers_explicit_username() {
2443 let spec = build_action_spec(
2444 "ListInstalledFlatpaks",
2445 &json!({ "username": "alice", "user": "bob" }),
2446 )
2447 .unwrap();
2448 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2450 }
2451
2452 #[test]
2455 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2456 let spec = build_action_spec(
2457 "InstallFlatpak",
2458 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2459 )
2460 .unwrap();
2461 assert_eq!(
2462 spec.mechanism,
2463 ActionMechanism::Command {
2464 program: "sudo",
2465 args: vec![
2466 "/usr/lib/sysknife/action-steps".to_string(),
2467 "flatpak".to_string(),
2468 "alice".to_string(),
2469 "install".to_string(),
2470 "--user".to_string(),
2471 "-y".to_string(),
2472 "flathub".to_string(),
2473 "org.mozilla.firefox".to_string(),
2474 ],
2475 }
2476 );
2477 }
2478
2479 #[test]
2480 fn build_spec_install_flatpak_injects_app_and_remote() {
2481 let spec = build_action_spec(
2482 "InstallFlatpak",
2483 &json!({
2484 "username": "alice",
2485 "app_id": "org.mozilla.firefox",
2486 "remote": "flathub"
2487 }),
2488 )
2489 .unwrap();
2490 assert_eq!(spec.action_name, "InstallFlatpak");
2491 assert_eq!(
2492 spec.mechanism,
2493 ActionMechanism::Command {
2494 program: "sudo",
2495 args: vec![
2496 "/usr/lib/sysknife/action-steps".to_string(),
2497 "flatpak".to_string(),
2498 "alice".to_string(),
2499 "install".to_string(),
2500 "--user".to_string(),
2501 "-y".to_string(),
2502 "flathub".to_string(),
2503 "org.mozilla.firefox".to_string(),
2504 ],
2505 }
2506 );
2507 }
2508
2509 #[test]
2510 fn build_spec_pin_deployment_injects_index() {
2511 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2512 assert_eq!(
2513 spec.mechanism,
2514 ActionMechanism::Command {
2515 program: "sudo",
2516 args: vec![
2517 "ostree".to_string(),
2518 "admin".to_string(),
2519 "pin".to_string(),
2520 "1".to_string()
2521 ],
2522 }
2523 );
2524 }
2525
2526 #[test]
2527 fn build_spec_unpin_deployment_includes_unpin_flag() {
2528 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2529 assert_eq!(
2530 spec.mechanism,
2531 ActionMechanism::Command {
2532 program: "sudo",
2533 args: vec![
2534 "ostree".to_string(),
2535 "admin".to_string(),
2536 "pin".to_string(),
2537 "--unpin".to_string(),
2538 "2".to_string(),
2539 ],
2540 }
2541 );
2542 }
2543
2544 #[test]
2545 fn require_u32_rejects_overflow() {
2546 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2547 assert!(
2548 matches!(err, ExecutorError::InvalidParam("index")),
2549 "expected InvalidParam(index), got: {err}"
2550 );
2551 }
2552
2553 #[test]
2554 fn build_spec_rebase_system_injects_target_ref() {
2555 let spec = build_action_spec(
2556 "RebaseSystem",
2557 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
2558 )
2559 .unwrap();
2560 assert_eq!(
2561 spec.mechanism,
2562 ActionMechanism::Command {
2563 program: "sudo",
2564 args: vec![
2565 "rpm-ostree".to_string(),
2566 "rebase".to_string(),
2567 "fedora/41/x86_64/silverblue".to_string(),
2568 ],
2569 }
2570 );
2571 }
2572
2573 #[test]
2574 fn build_spec_set_kernel_arguments_appends_and_deletes() {
2575 let spec = build_action_spec(
2576 "SetKernelArguments",
2577 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
2578 )
2579 .unwrap();
2580 assert_eq!(
2581 spec.mechanism,
2582 ActionMechanism::Command {
2583 program: "sudo",
2584 args: vec![
2585 "rpm-ostree".to_string(),
2586 "kargs".to_string(),
2587 "--append=nomodeset".to_string(),
2588 "--delete=quiet".to_string(),
2589 ],
2590 }
2591 );
2592 }
2593
2594 #[test]
2595 fn build_spec_set_kernel_arguments_with_empty_arrays() {
2596 let spec =
2597 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
2598 assert_eq!(
2599 spec.mechanism,
2600 ActionMechanism::Command {
2601 program: "sudo",
2602 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2603 }
2604 );
2605 }
2606
2607 #[test]
2608 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
2609 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
2610 assert_eq!(
2611 spec.mechanism,
2612 ActionMechanism::Command {
2613 program: "sudo",
2614 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2615 }
2616 );
2617 }
2618
2619 #[test]
2622 fn delete_user_rejects_critical_account() {
2623 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
2624 assert!(
2625 matches!(err, ExecutorError::InvalidParam("username")),
2626 "expected InvalidParam(username), got: {err}"
2627 );
2628 }
2629
2630 #[test]
2631 fn delete_group_rejects_critical_group() {
2632 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
2633 assert!(
2634 matches!(err, ExecutorError::InvalidParam("group")),
2635 "expected InvalidParam(group), got: {err}"
2636 );
2637 }
2638
2639 #[test]
2640 fn lock_user_account_rejects_critical_account() {
2641 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
2642 assert!(
2643 matches!(err, ExecutorError::InvalidParam("username")),
2644 "expected InvalidParam(username), got: {err}"
2645 );
2646 }
2647
2648 #[test]
2649 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
2650 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
2651 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
2652 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
2653 }
2654
2655 #[test]
2658 fn build_spec_add_package_repository_rejects_path_traversal() {
2659 let err = build_action_spec(
2660 "AddPackageRepository",
2661 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
2662 )
2663 .unwrap_err();
2664 assert!(
2665 matches!(err, ExecutorError::InvalidParam("repo_id")),
2666 "expected InvalidParam(repo_id), got: {err}"
2667 );
2668 }
2669
2670 #[test]
2671 fn build_spec_add_package_repository_rejects_newline_in_url() {
2672 let err = build_action_spec(
2673 "AddPackageRepository",
2674 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2675 )
2676 .unwrap_err();
2677 assert!(
2678 matches!(err, ExecutorError::InvalidParam("repo_url")),
2679 "expected InvalidParam(repo_url), got: {err}"
2680 );
2681 }
2682
2683 #[test]
2684 fn build_spec_add_package_repository_accepts_valid_repo_id() {
2685 let spec = build_action_spec(
2686 "AddPackageRepository",
2687 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2688 )
2689 .unwrap();
2690 assert_eq!(spec.action_name, "AddPackageRepository");
2691 }
2692
2693 #[test]
2694 fn build_spec_remove_package_repository_rejects_path_traversal() {
2695 let err = build_action_spec(
2696 "RemovePackageRepository",
2697 &json!({ "repo_id": "../../etc/passwd" }),
2698 )
2699 .unwrap_err();
2700 assert!(
2701 matches!(err, ExecutorError::InvalidParam("repo_id")),
2702 "expected InvalidParam(repo_id), got: {err}"
2703 );
2704 }
2705
2706 #[tokio::test]
2707 async fn execute_spec_command_captures_stdout() {
2708 let spec = ActionSpec {
2709 action_name: "GetSystemState",
2710 mechanism: ActionMechanism::Command {
2711 program: "echo",
2712 args: vec!["hello".to_string()],
2713 },
2714 risk_level: RiskLevel::Low,
2715 reboot_required: false,
2716 rollback_available: false,
2717 };
2718 let out = execute_spec(&spec).await.unwrap();
2719 assert_eq!(out.stdout.trim(), "hello");
2720 assert_eq!(out.exit_code, 0);
2721 }
2722
2723 #[test]
2731 fn action_not_stopped_names_the_group_and_the_timeout() {
2732 let err = ExecutorError::ActionNotStopped {
2733 program: "rpm-ostree".to_string(),
2734 pgid: 4242,
2735 timeout_secs: 7200,
2736 };
2737 let msg = err.to_string();
2738 assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
2739 assert!(msg.contains("7200"), "must name the timeout: {msg}");
2740 assert!(
2741 msg.contains("could not be confirmed stopped"),
2742 "must say what happened: {msg}"
2743 );
2744 assert!(
2745 msg.contains("rollback was skipped"),
2746 "must say the rollback was skipped: {msg}"
2747 );
2748 }
2749
2750 #[tokio::test]
2755 async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
2756 let mut child = tokio::process::Command::new("sh")
2757 .args(["-c", "sleep 3600 & wait"])
2758 .process_group(0)
2759 .stdin(Stdio::null())
2760 .stdout(Stdio::null())
2761 .stderr(Stdio::null())
2762 .spawn()
2763 .expect("spawn");
2764 SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
2765 let started = tokio::time::Instant::now();
2766 let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
2767 let elapsed = started.elapsed();
2768
2769 assert!(
2770 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
2771 "a killable group must report a plain stopped-timeout, got: {err:?}"
2772 );
2773 assert!(
2774 elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
2775 "must return promptly, took {elapsed:?}"
2776 );
2777 assert_eq!(
2781 SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
2782 0,
2783 "a killable group must not escalate to the privileged reaper"
2784 );
2785 }
2786
2787 #[tokio::test]
2795 async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
2796 let dir = tempfile::tempdir().expect("tempdir");
2797 let pgid_file = dir.path().join("pgid");
2798 let script = format!(
2802 "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
2803 pgid_file.display()
2804 );
2805 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2806 let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
2807 .await
2808 .expect_err("non-UTF-8 stdout must fail the action");
2809 assert!(
2810 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
2811 "the read failure itself must be surfaced, got: {err:?}"
2812 );
2813
2814 let pgid: i32 = std::fs::read_to_string(&pgid_file)
2815 .expect("the shell must have recorded its pgid")
2816 .trim()
2817 .parse()
2818 .expect("pgid");
2819 assert!(
2820 probe_group(pgid).is_gone(),
2821 "process group {pgid} survived the read failure; \
2822 the privileged child was orphaned"
2823 );
2824 }
2825
2826 #[test]
2827 fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
2828 assert_eq!(
2830 reap_command(
2831 99,
2832 "sudo",
2833 &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
2834 ),
2835 vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
2836 );
2837 assert_eq!(
2839 reap_command(
2840 99,
2841 "sudo",
2842 &[
2843 "env".to_string(),
2844 "apt-get".to_string(),
2845 "install".to_string()
2846 ]
2847 ),
2848 root_group_kill_argv("TERM", 99)
2849 );
2850 }
2851
2852 #[test]
2853 fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
2854 assert_eq!(
2857 root_group_kill_argv("KILL", 4242),
2858 vec!["-n", "/usr/bin/kill", "-s", "KILL", "--", "-4242"]
2859 );
2860 assert_eq!(
2861 root_group_kill_argv("TERM", 7)[3..].to_vec(),
2862 vec!["TERM", "--", "-7"]
2863 );
2864 }
2865
2866 #[test]
2867 fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
2868 let sudo = |a: &[&str]| {
2869 is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
2870 };
2871 assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
2873 assert!(sudo(&[
2874 "rpm-ostree",
2875 "rebase",
2876 "fedora:fedora/40/x86_64/silverblue"
2877 ]));
2878 assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
2879 assert!(!sudo(&[
2882 "env",
2883 "DEBIAN_FRONTEND=noninteractive",
2884 "apt-get",
2885 "install",
2886 "vim"
2887 ]));
2888 assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
2889 assert!(!is_rpm_ostree_action("sudo", &[]));
2890 assert!(is_rpm_ostree_action(
2892 "/usr/bin/rpm-ostree",
2893 &["deploy".to_string()]
2894 ));
2895 assert!(!is_rpm_ostree_action(
2898 "sudo",
2899 &["explain-rpm-ostree-usage".to_string()]
2900 ));
2901 }
2902
2903 #[tokio::test]
2906 async fn real_executor_streams_each_stdout_line() {
2907 let spec = ActionSpec {
2908 action_name: "GetSystemState",
2909 mechanism: ActionMechanism::Command {
2910 program: "printf",
2911 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2912 },
2913 risk_level: RiskLevel::Low,
2914 reboot_required: false,
2915 rollback_available: false,
2916 };
2917 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2918 let out = RealActionExecutor
2919 .execute_with_progress(&spec, tx)
2920 .await
2921 .unwrap();
2922
2923 let mut lines = Vec::new();
2924 while let Ok(line) = rx.try_recv() {
2925 lines.push(line);
2926 }
2927 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2928 assert_eq!(out.exit_code, 0);
2929 assert!(out.stdout.contains("line-two"));
2930 }
2931
2932 #[tokio::test]
2933 async fn execute_spec_file_write_creates_file() {
2934 let dir = tempdir().unwrap();
2935 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2936 let spec = ActionSpec {
2937 action_name: "AddPackageRepository",
2938 mechanism: ActionMechanism::FileWrite {
2939 path: path.clone(),
2940 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2941 },
2942 risk_level: RiskLevel::Medium,
2943 reboot_required: false,
2944 rollback_available: true,
2945 };
2946 let out = execute_spec(&spec).await.unwrap();
2947 assert_eq!(out.exit_code, 0);
2948 assert_eq!(
2949 std::fs::read_to_string(&path).unwrap(),
2950 "[repo]\nbaseurl=https://example.test\n"
2951 );
2952 }
2953
2954 #[tokio::test]
2955 async fn execute_spec_file_patch_replaces_first_occurrence() {
2956 let dir = tempdir().unwrap();
2957 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2958 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2959 let spec = ActionSpec {
2960 action_name: "EnablePackageRepository",
2961 mechanism: ActionMechanism::FilePatch {
2962 path: path.clone(),
2963 search: "enabled=0".to_string(),
2964 replace: "enabled=1".to_string(),
2965 },
2966 risk_level: RiskLevel::Medium,
2967 reboot_required: false,
2968 rollback_available: true,
2969 };
2970 execute_spec(&spec).await.unwrap();
2971 assert_eq!(
2972 std::fs::read_to_string(&path).unwrap(),
2973 "[myrepo]\nenabled=1\n"
2974 );
2975 }
2976
2977 #[tokio::test]
2978 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2979 let dir = tempdir().unwrap();
2980 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2981 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2982 let spec = ActionSpec {
2983 action_name: "EnablePackageRepository",
2984 mechanism: ActionMechanism::FilePatch {
2985 path: path.clone(),
2986 search: "enabled=0".to_string(),
2987 replace: "enabled=1".to_string(),
2988 },
2989 risk_level: RiskLevel::Medium,
2990 reboot_required: false,
2991 rollback_available: true,
2992 };
2993 let out = execute_spec(&spec).await.unwrap();
2994 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2995 assert!(
2996 out.stderr.contains("search string not found in file"),
2997 "stderr should explain the failure: {}",
2998 out.stderr
2999 );
3000 assert_eq!(
3002 std::fs::read_to_string(&path).unwrap(),
3003 "[myrepo]\nenabled=1\n"
3004 );
3005 }
3006
3007 #[tokio::test]
3008 async fn execute_spec_file_patch_allows_empty_search_string() {
3009 let dir = tempdir().unwrap();
3012 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
3013 std::fs::write(&path, "hello").unwrap();
3014 let spec = ActionSpec {
3015 action_name: "Test",
3016 mechanism: ActionMechanism::FilePatch {
3017 path: path.clone(),
3018 search: String::new(),
3019 replace: "prefix-".to_string(),
3020 },
3021 risk_level: RiskLevel::Low,
3022 reboot_required: false,
3023 rollback_available: false,
3024 };
3025 let out = execute_spec(&spec).await.unwrap();
3026 assert_eq!(out.exit_code, 0);
3027 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3028 }
3029
3030 #[tokio::test]
3031 async fn execute_spec_file_delete_removes_file() {
3032 let dir = tempdir().unwrap();
3033 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3034 std::fs::write(&path, "[myrepo]\n").unwrap();
3035 let spec = ActionSpec {
3036 action_name: "RemovePackageRepository",
3037 mechanism: ActionMechanism::FileDelete { path: path.clone() },
3038 risk_level: RiskLevel::Medium,
3039 reboot_required: false,
3040 rollback_available: true,
3041 };
3042 execute_spec(&spec).await.unwrap();
3043 assert!(!std::path::Path::new(&path).exists());
3044 }
3045
3046 #[tokio::test]
3047 async fn execute_spec_file_scan_lists_directory_entries() {
3048 let dir = tempdir().unwrap();
3049 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3050 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3051 let spec = ActionSpec {
3052 action_name: "ListPackageRepositories",
3053 mechanism: ActionMechanism::FileScan {
3054 path: dir.path().to_string_lossy().into_owned(),
3055 },
3056 risk_level: RiskLevel::Low,
3057 reboot_required: false,
3058 rollback_available: false,
3059 };
3060 let out = execute_spec(&spec).await.unwrap();
3061 assert!(
3062 out.stdout.contains("a.repo"),
3063 "expected a.repo in: {}",
3064 out.stdout
3065 );
3066 assert!(
3067 out.stdout.contains("b.repo"),
3068 "expected b.repo in: {}",
3069 out.stdout
3070 );
3071 assert_eq!(out.exit_code, 0);
3072 }
3073
3074 #[test]
3077 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3078 let spec = rollback_spec_for("UpdateSystem").unwrap();
3079 assert_eq!(spec.action_name, "RollbackDeployment");
3080 assert!(
3081 matches!(
3082 &spec.mechanism,
3083 ActionMechanism::Command { program: "rpm-ostree", args }
3084 if args == &["rollback".to_string()]
3085 ),
3086 "expected rpm-ostree rollback, got: {:?}",
3087 spec.mechanism
3088 );
3089 assert!(!spec.rollback_available, "rollback spec must not recurse");
3090 }
3091
3092 #[test]
3093 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3094 assert!(rollback_spec_for("InstallPackages").is_some());
3095 }
3096
3097 #[test]
3098 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3099 assert!(rollback_spec_for("RemovePackages").is_some());
3100 }
3101
3102 #[test]
3103 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3104 assert!(rollback_spec_for("RebaseSystem").is_some());
3105 }
3106
3107 #[test]
3108 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3109 assert!(rollback_spec_for("SetKernelArguments").is_some());
3110 }
3111
3112 #[test]
3113 fn rollback_spec_for_read_only_action_returns_none() {
3114 assert!(rollback_spec_for("GetSystemState").is_none());
3115 assert!(rollback_spec_for("ListUsers").is_none());
3116 assert!(rollback_spec_for("GetFirewallState").is_none());
3117 }
3118
3119 #[test]
3120 fn rollback_spec_for_non_rollbackable_actions_return_none() {
3121 assert!(rollback_spec_for("AddUserToGroup").is_none());
3122 assert!(rollback_spec_for("DeleteUser").is_none());
3123 assert!(rollback_spec_for("CleanupDeployments").is_none());
3124 assert!(rollback_spec_for("RollbackDeployment").is_none());
3126 }
3127
3128 #[test]
3131 fn public_key_accepts_valid_ed25519() {
3132 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3133 assert!(validated_public_key(key).is_ok());
3134 }
3135
3136 #[test]
3137 fn public_key_accepts_valid_rsa() {
3138 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3139 assert!(validated_public_key(key).is_ok());
3140 }
3141
3142 #[test]
3143 fn public_key_rejects_empty() {
3144 assert!(matches!(
3145 validated_public_key(""),
3146 Err(ExecutorError::InvalidParam("public_key"))
3147 ));
3148 }
3149
3150 #[test]
3151 fn public_key_rejects_unknown_prefix() {
3152 assert!(matches!(
3153 validated_public_key("sk-rsa AAAA... user@host"),
3154 Err(ExecutorError::InvalidParam("public_key"))
3155 ));
3156 assert!(matches!(
3157 validated_public_key("AAAAB3Nz... user@host"),
3158 Err(ExecutorError::InvalidParam("public_key"))
3159 ));
3160 }
3161
3162 #[test]
3163 fn public_key_rejects_single_quote() {
3164 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3165 assert!(matches!(
3166 validated_public_key(key),
3167 Err(ExecutorError::InvalidParam("public_key"))
3168 ));
3169 }
3170
3171 #[test]
3179 fn package_actions_reject_a_local_file_target() {
3180 let reject: &[(&str, serde_json::Value)] = &[
3181 ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3182 ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3183 ("AptPurge", json!({"package": "./evil.deb"})),
3184 ("AptHold", json!({"package": "/tmp/evil.deb"})),
3185 ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3186 ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3187 ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3188 ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3189 (
3190 "ReplaceLayeredPackage",
3191 json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3192 ),
3193 (
3194 "ReplaceLayeredPackage",
3195 json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3196 ),
3197 ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3198 ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3199 ];
3200 for (action, params) in reject {
3201 assert!(
3202 matches!(
3203 build_action_spec(action, params),
3204 Err(ExecutorError::InvalidParam(_))
3205 ),
3206 "{action} must refuse a local-file package target: {params}"
3207 );
3208 }
3209 let accept: &[(&str, serde_json::Value)] = &[
3212 ("AptInstall", json!({"package": "nginx"})),
3213 ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3214 (
3215 "InstallPackages",
3216 json!({"packages": ["nginx", "python3-pip"]}),
3217 ),
3218 ];
3219 for (action, params) in accept {
3220 assert!(
3221 build_action_spec(action, params).is_ok(),
3222 "{action} must accept a real package spec: {params}"
3223 );
3224 }
3225 }
3226
3227 #[test]
3232 fn activating_actions_refuse_a_root_shell_unit() {
3233 for action in ["StartService", "RestartService", "UnmaskService"] {
3234 for unit in [
3235 "debug-shell.service",
3236 "emergency.target",
3237 "rescue.service",
3238 "runlevel1.target",
3239 ] {
3240 assert!(
3241 matches!(
3242 build_action_spec(action, &json!({"unit": unit})),
3243 Err(ExecutorError::InvalidParam("unit"))
3244 ),
3245 "{action} must not activate {unit}"
3246 );
3247 }
3248 assert!(
3249 build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3250 "{action} must still activate an ordinary unit"
3251 );
3252 }
3253 assert!(
3254 build_action_spec(
3255 "SetServiceEnabled",
3256 &json!({"unit": "debug-shell.service", "enabled": true})
3257 )
3258 .is_err(),
3259 "enabling a root-shell unit brings it up at boot and must be refused"
3260 );
3261 assert!(
3262 build_action_spec(
3263 "SetServiceEnabled",
3264 &json!({"unit": "debug-shell.service", "enabled": false})
3265 )
3266 .is_ok(),
3267 "disabling a root-shell unit is a mitigation and must stay reachable"
3268 );
3269 for action in ["StopService", "MaskService"] {
3272 assert!(
3273 build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3274 "{action} on debug-shell is a mitigation and must stay allowed"
3275 );
3276 }
3277 }
3278
3279 #[test]
3280 fn authorized_keys_actions_reject_a_traversal_username() {
3281 for action in [
3288 "GetAuthorizedKeys",
3289 "AddAuthorizedKey",
3290 "RemoveAuthorizedKey",
3291 ] {
3292 for bad in ["../../etc", "..", "root/../../etc"] {
3293 let err = build_action_spec(
3294 action,
3295 &json!({
3296 "username": bad,
3297 "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h",
3298 }),
3299 )
3300 .unwrap_err();
3301 assert!(
3302 matches!(err, ExecutorError::InvalidParam("username")),
3303 "{action} must reject username {bad:?}, got {err:?}"
3304 );
3305 }
3306 }
3307 }
3308
3309 #[test]
3310 fn ip_taking_actions_reject_a_malformed_address() {
3311 let cases: [(&str, serde_json::Value); 3] = [
3314 (
3315 "ResolvectlSetDns",
3316 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
3317 ),
3318 (
3319 "Fail2banBanIp",
3320 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
3321 ),
3322 (
3323 "Fail2banUnbanIp",
3324 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
3325 ),
3326 ];
3327 for (action, params) in cases {
3328 let err = build_action_spec(action, ¶ms).unwrap_err();
3329 assert!(
3330 matches!(err, ExecutorError::InvalidIpAddress { .. }),
3331 "{action} must reject a malformed IP, got {err:?}"
3332 );
3333 }
3334 }
3335
3336 #[test]
3337 fn public_key_rejects_pipe_metacharacter() {
3338 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
3342 assert!(matches!(
3343 validated_public_key(key),
3344 Err(ExecutorError::InvalidParam("public_key"))
3345 ));
3346 }
3347
3348 #[test]
3349 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
3350 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
3357 assert!(validated_public_key(key).is_ok());
3358 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
3361 }
3362
3363 #[test]
3364 fn public_key_rejects_shell_metacharacters() {
3365 for (metachar, desc) in [
3366 (';', "semicolon"),
3367 ('`', "backtick"),
3368 ('$', "dollar"),
3369 ('\\', "backslash"),
3370 ('&', "ampersand"),
3371 ] {
3372 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
3373 assert!(
3374 matches!(
3375 validated_public_key(&key),
3376 Err(ExecutorError::InvalidParam("public_key"))
3377 ),
3378 "{desc} should be rejected"
3379 );
3380 }
3381 }
3382
3383 #[test]
3384 fn public_key_rejects_newline() {
3385 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
3386 assert!(matches!(
3387 validated_public_key(key),
3388 Err(ExecutorError::InvalidParam("public_key"))
3389 ));
3390 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
3391 assert!(matches!(
3392 validated_public_key(key_cr),
3393 Err(ExecutorError::InvalidParam("public_key"))
3394 ));
3395 }
3396
3397 #[test]
3398 fn public_key_rejects_too_long() {
3399 let long_payload = "A".repeat(8192);
3401 let key = format!("ssh-ed25519 {long_payload} user@host");
3402 assert!(matches!(
3403 validated_public_key(&key),
3404 Err(ExecutorError::InvalidParam("public_key"))
3405 ));
3406 }
3407
3408 #[test]
3411 fn str_array_or_empty_rejects_non_string_element() {
3412 let params = json!({ "packages": ["vim", 42, "curl"] });
3413 assert!(matches!(
3414 str_array_or_empty(¶ms, "packages"),
3415 Err(ExecutorError::InvalidParam("packages"))
3416 ));
3417 }
3418
3419 #[test]
3420 fn str_array_or_empty_accepts_string_array() {
3421 let params = json!({ "packages": ["vim", "curl"] });
3422 assert_eq!(
3423 str_array_or_empty(¶ms, "packages").unwrap(),
3424 vec!["vim".to_string(), "curl".to_string()]
3425 );
3426 }
3427
3428 #[test]
3429 fn str_array_or_empty_returns_empty_when_key_absent() {
3430 let params = json!({});
3431 assert_eq!(
3432 str_array_or_empty(¶ms, "packages").unwrap(),
3433 Vec::<String>::new()
3434 );
3435 }
3436
3437 #[test]
3440 fn kernel_arg_allows_safe_args() {
3441 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3442 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3443 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3444 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3445 }
3446
3447 #[test]
3448 fn kernel_arg_blocks_init_override() {
3449 assert!(matches!(
3450 validated_safe_kernel_arg("init=/bin/sh", "add"),
3451 Err(ExecutorError::InvalidParam("add"))
3452 ));
3453 assert!(matches!(
3454 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3455 Err(ExecutorError::InvalidParam("add"))
3456 ));
3457 }
3458
3459 #[test]
3460 fn kernel_arg_blocks_selinux_disable() {
3461 assert!(matches!(
3462 validated_safe_kernel_arg("selinux=0", "add"),
3463 Err(ExecutorError::InvalidParam("add"))
3464 ));
3465 assert!(matches!(
3466 validated_safe_kernel_arg("enforcing=0", "add"),
3467 Err(ExecutorError::InvalidParam("add"))
3468 ));
3469 }
3470
3471 #[test]
3472 fn kernel_arg_blocks_security_override() {
3473 assert!(matches!(
3474 validated_safe_kernel_arg("security=none", "add"),
3475 Err(ExecutorError::InvalidParam("add"))
3476 ));
3477 }
3478
3479 #[test]
3480 fn kernel_arg_blocks_module_blacklist() {
3481 assert!(matches!(
3482 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3483 Err(ExecutorError::InvalidParam("add"))
3484 ));
3485 }
3486
3487 #[test]
3495 fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
3496 for arg in [
3499 "lockdown=confidentiality",
3500 "module.sig_enforce=1",
3501 "selinux=1",
3502 "enforcing=1",
3503 "apparmor=1",
3504 "security=selinux",
3505 "pti=on",
3506 "mitigations=auto,nosmt",
3507 "init_on_alloc=1",
3508 "init_on_free=1",
3509 "slab_nomerge",
3510 "page_alloc.shuffle=1",
3511 "randomize_kstack_offset=on",
3512 "slub_debug=FZP",
3513 "vsyscall=none",
3514 "debugfs=off",
3515 "iommu=force",
3516 "intel_iommu=on",
3517 "amd_iommu=force_isolation",
3518 ] {
3519 assert!(
3520 matches!(
3521 validated_safe_kernel_arg_removal(arg, "remove"),
3522 Err(ExecutorError::InvalidParam("remove"))
3523 ),
3524 "removing {arg} is a boot-security downgrade and must be refused"
3525 );
3526 }
3527 }
3528
3529 #[test]
3533 fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
3534 for arg in [
3535 "mitigations=off",
3536 "selinux=0",
3537 "enforcing=0",
3538 "apparmor=0",
3539 "pti=off",
3540 "lockdown=none",
3541 "debugfs=on",
3542 "init=/bin/sh",
3543 "single",
3544 ] {
3545 assert!(
3546 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3547 "{arg} weakens the host, so removing it must stay allowed"
3548 );
3549 }
3550 }
3551
3552 #[test]
3553 fn kernel_arg_removal_allows_ordinary_args() {
3554 for arg in [
3555 "quiet",
3556 "splash",
3557 "nomodeset",
3558 "console=ttyS0,115200",
3559 "rd.driver.blacklist=nouveau",
3560 ] {
3561 assert!(
3562 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3563 "{arg} carries no boot-security meaning and must stay removable"
3564 );
3565 }
3566 }
3567
3568 #[test]
3569 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
3570 assert!(matches!(
3571 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
3572 Err(ExecutorError::InvalidParam("add"))
3573 ));
3574 assert!(matches!(
3575 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
3576 Err(ExecutorError::InvalidParam("add"))
3577 ));
3578 assert!(matches!(
3579 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
3580 Err(ExecutorError::InvalidParam("add"))
3581 ));
3582 }
3583
3584 #[test]
3585 fn kernel_arg_blocks_apparmor_disable() {
3586 assert!(matches!(
3587 validated_safe_kernel_arg("apparmor=0", "add"),
3588 Err(ExecutorError::InvalidParam("add"))
3589 ));
3590 }
3591
3592 #[test]
3593 fn kernel_arg_blocks_mitigations_off() {
3594 assert!(matches!(
3595 validated_safe_kernel_arg("mitigations=off", "add"),
3596 Err(ExecutorError::InvalidParam("add"))
3597 ));
3598 }
3599
3600 #[test]
3601 fn kernel_arg_blocks_lockdown_override() {
3602 assert!(matches!(
3603 validated_safe_kernel_arg("lockdown=none", "add"),
3604 Err(ExecutorError::InvalidParam("add"))
3605 ));
3606 }
3607
3608 #[test]
3609 fn kernel_arg_blocks_pti_off() {
3610 assert!(matches!(
3611 validated_safe_kernel_arg("pti=off", "add"),
3612 Err(ExecutorError::InvalidParam("add"))
3613 ));
3614 }
3615
3616 #[test]
3617 fn kernel_arg_blocks_smap_smep_disable() {
3618 assert!(matches!(
3619 validated_safe_kernel_arg("nosmap", "add"),
3620 Err(ExecutorError::InvalidParam("add"))
3621 ));
3622 assert!(matches!(
3623 validated_safe_kernel_arg("nosmep", "add"),
3624 Err(ExecutorError::InvalidParam("add"))
3625 ));
3626 }
3627
3628 #[test]
3629 fn kernel_arg_blocks_single_user_shortcuts() {
3630 assert!(matches!(
3632 validated_safe_kernel_arg("single", "add"),
3633 Err(ExecutorError::InvalidParam("add"))
3634 ));
3635 assert!(matches!(
3636 validated_safe_kernel_arg("s", "add"),
3637 Err(ExecutorError::InvalidParam("add"))
3638 ));
3639 assert!(matches!(
3640 validated_safe_kernel_arg("1", "add"),
3641 Err(ExecutorError::InvalidParam("add"))
3642 ));
3643 }
3644
3645 #[test]
3646 fn signal_process_guardrails() {
3647 for bad_pid in [0, 1] {
3649 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
3650 assert!(
3651 matches!(err, ExecutorError::InvalidParam("pid")),
3652 "pid {bad_pid} must be rejected, got {err:?}"
3653 );
3654 }
3655 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
3657 .unwrap_err();
3658 assert!(
3659 matches!(err, ExecutorError::InvalidParam("signal")),
3660 "got {err:?}"
3661 );
3662 assert!(matches!(
3664 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
3665 ExecutorError::MissingParam("pid")
3666 ));
3667 let spec = build_action_spec(
3669 "SignalProcess",
3670 &json!({ "pid": "4242", "signal": "sigkill" }),
3671 )
3672 .unwrap();
3673 assert_eq!(spec.action_name, "SignalProcess");
3674 assert_eq!(
3675 spec.mechanism,
3676 ActionMechanism::Command {
3677 program: "sudo",
3678 args: vec![
3679 "kill".to_string(),
3680 "-s".to_string(),
3681 "KILL".to_string(),
3682 "4242".to_string()
3683 ],
3684 }
3685 );
3686 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
3688 if let ActionMechanism::Command { args, .. } = spec.mechanism {
3689 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
3690 } else {
3691 panic!("expected Command mechanism");
3692 }
3693 }
3694
3695 #[test]
3696 fn create_scheduled_job_validates_name_command_schedule() {
3697 assert!(matches!(
3699 build_action_spec(
3700 "CreateScheduledJob",
3701 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
3702 )
3703 .unwrap_err(),
3704 ExecutorError::InvalidParam("command")
3705 ));
3706 assert!(matches!(
3708 build_action_spec(
3709 "CreateScheduledJob",
3710 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
3711 )
3712 .unwrap_err(),
3713 ExecutorError::InvalidParam("name")
3714 ));
3715 assert!(matches!(
3717 build_action_spec(
3718 "CreateScheduledJob",
3719 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
3720 )
3721 .unwrap_err(),
3722 ExecutorError::InvalidParam("schedule")
3723 ));
3724 let spec = build_action_spec(
3726 "CreateScheduledJob",
3727 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
3728 )
3729 .unwrap();
3730 assert_eq!(
3731 spec.mechanism,
3732 ActionMechanism::Command {
3733 program: "sudo",
3734 args: vec![
3735 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
3736 "--name".to_string(),
3737 "nightly-backup".to_string(),
3738 "--command".to_string(),
3739 "/usr/bin/backup --full".to_string(),
3740 "--schedule".to_string(),
3741 "*-*-* 02:00:00".to_string(),
3742 ],
3743 }
3744 );
3745 }
3746
3747 #[test]
3748 fn set_sshd_option_allowlist_guardrails() {
3749 assert!(matches!(
3751 build_action_spec(
3752 "SetSshdOption",
3753 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
3754 )
3755 .unwrap_err(),
3756 ExecutorError::InvalidParam("option")
3757 ));
3758 assert!(matches!(
3760 build_action_spec(
3761 "SetSshdOption",
3762 &json!({ "option": "PermitRootLogin", "value": "maybe" })
3763 )
3764 .unwrap_err(),
3765 ExecutorError::InvalidParam("value")
3766 ));
3767 let spec = build_action_spec(
3769 "SetSshdOption",
3770 &json!({ "option": "PasswordAuthentication", "value": "no" }),
3771 )
3772 .unwrap();
3773 assert_eq!(
3774 spec.mechanism,
3775 ActionMechanism::Command {
3776 program: "sudo",
3777 args: vec![
3778 "/usr/lib/sysknife/sshd-option-edit".to_string(),
3779 "--option".to_string(),
3780 "PasswordAuthentication".to_string(),
3781 "--value".to_string(),
3782 "no".to_string(),
3783 ],
3784 }
3785 );
3786 }
3787
3788 #[test]
3789 fn configure_unattended_upgrades_toggles_helper_flag() {
3790 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
3791 let spec = build_action_spec(
3792 "ConfigureUnattendedUpgrades",
3793 &json!({ "enabled": enabled }),
3794 )
3795 .unwrap();
3796 assert_eq!(
3797 spec.mechanism,
3798 ActionMechanism::Command {
3799 program: "sudo",
3800 args: vec![
3801 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
3802 flag.to_string(),
3803 ],
3804 }
3805 );
3806 }
3807 assert!(matches!(
3808 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
3809 ExecutorError::MissingParam("enabled")
3810 ));
3811 }
3812
3813 #[test]
3814 fn grub_set_kargs_append_blocks_single_user_shortcut() {
3815 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
3823 let err = build_action_spec(
3824 "GrubSetKargs",
3825 &json!({ "append": [dangerous], "delete": [] }),
3826 )
3827 .unwrap_err();
3828 assert!(
3829 matches!(err, ExecutorError::InvalidParam("append")),
3830 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
3831 );
3832 }
3833 assert!(build_action_spec(
3835 "GrubSetKargs",
3836 &json!({ "append": ["quiet"], "delete": [] })
3837 )
3838 .is_ok());
3839 }
3840
3841 #[test]
3842 fn kernel_arg_build_spec_rejects_dangerous_arg() {
3843 let err = build_action_spec(
3849 "SetKernelArguments",
3850 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
3851 )
3852 .unwrap_err();
3853 assert!(
3854 matches!(err, ExecutorError::InvalidParam("add")),
3855 "expected InvalidParam(add), got {err:?}"
3856 );
3857 }
3858
3859 #[test]
3860 fn grub_set_kargs_cannot_delete_a_protective_karg() {
3861 for karg in [
3868 "apparmor=1",
3869 "lockdown=confidentiality",
3870 "mitigations=auto",
3871 "pti=on",
3872 "selinux=1",
3873 ] {
3874 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
3875 .unwrap_err();
3876 assert!(
3877 matches!(err, ExecutorError::InvalidParam("delete")),
3878 "deleting {karg:?} must be refused, got {err:?}"
3879 );
3880 }
3881 }
3882
3883 #[test]
3888 fn rollback_available_matches_rollback_spec_for_all_actions() {
3889 for spec in crate::actions::all_specs() {
3897 let has_rollback = rollback_spec_for(spec.action_name).is_some();
3898 assert_eq!(
3899 spec.rollback_available,
3900 has_rollback,
3901 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
3902 spec.action_name,
3903 spec.rollback_available,
3904 if has_rollback { "Some" } else { "None" },
3905 );
3906 }
3907 }
3908
3909 #[test]
3917 fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
3918 for home in [
3919 "../../etc/skel",
3920 "/home/../etc",
3921 "home/alice",
3922 "/home/alice/../../../root",
3923 ] {
3924 let got =
3925 build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
3926 assert!(
3927 got.is_err(),
3928 "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
3929 );
3930 }
3931 build_action_spec(
3934 "CreateUser",
3935 &json!({ "username": "alice", "home": "/home/alice" }),
3936 )
3937 .expect("a plain absolute home must still be accepted");
3938 }
3939
3940 #[test]
3946 fn create_user_is_high_risk() {
3947 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
3948 assert_eq!(
3949 spec.risk_level,
3950 RiskLevel::High,
3951 "CreateUser must be High (T1136.001 Persistence)"
3952 );
3953 }
3954
3955 #[test]
3956 fn configure_firewall_is_high_risk() {
3957 let spec = build_action_spec(
3958 "ConfigureFirewall",
3959 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
3960 )
3961 .unwrap();
3962 assert_eq!(
3963 spec.risk_level,
3964 RiskLevel::High,
3965 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
3966 );
3967 }
3968
3969 #[test]
3970 fn mask_service_is_high_risk() {
3971 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
3972 assert_eq!(
3973 spec.risk_level,
3974 RiskLevel::High,
3975 "MaskService must be High (T1562.001 Impair Defenses)"
3976 );
3977 }
3978
3979 #[test]
3980 fn add_package_repository_is_high_risk() {
3981 let spec = build_action_spec(
3982 "AddPackageRepository",
3983 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
3984 )
3985 .unwrap();
3986 assert_eq!(
3987 spec.risk_level,
3988 RiskLevel::High,
3989 "AddPackageRepository must be High (supply-chain vector)"
3990 );
3991 }
3992
3993 #[test]
3994 fn set_dns_servers_is_high_risk() {
3995 let spec = build_action_spec(
3996 "SetDnsServers",
3997 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
3998 )
3999 .unwrap();
4000 assert_eq!(
4001 spec.risk_level,
4002 RiskLevel::High,
4003 "SetDnsServers must be High (DNS hijacking / T1557)"
4004 );
4005 }
4006}