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