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")?;
856 if name.is_empty()
857 || name.len() > 64
858 || !name
859 .chars()
860 .next()
861 .is_some_and(|c| c.is_ascii_alphanumeric())
862 || !name
863 .chars()
864 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
865 {
866 return Err(ExecutorError::InvalidParam("name"));
867 }
868 let command = require_str(params, "command")?;
871 if command.is_empty()
872 || command.len() > MAX_SCHEDULED_COMMAND_LEN
873 || command.chars().any(|c| c.is_control())
874 {
875 return Err(ExecutorError::InvalidParam("command"));
876 }
877 let schedule = require_str(params, "schedule")?;
880 if schedule.is_empty()
881 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
882 || !schedule.chars().all(|c| {
883 c.is_ascii_alphanumeric()
884 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
885 })
886 {
887 return Err(ExecutorError::InvalidParam("schedule"));
888 }
889 Ok(services::create_scheduled_job(name, command, schedule))
890 }
891 "GetServiceResourceLimits" => {
892 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
893 Ok(services::get_service_resource_limits(&unit))
894 }
895 "SetServiceResourceLimits" => {
896 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
897 let mut assignments = Vec::new();
900 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
901 assignments.push(format!("MemoryMax={v}"));
902 }
903 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
904 assignments.push(format!("MemoryHigh={v}"));
905 }
906 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
907 assignments.push(format!("CPUQuota={v}"));
908 }
909 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
910 assignments.push(format!("TasksMax={v}"));
911 }
912 if assignments.is_empty() {
913 return Err(ExecutorError::MissingParam("memory_max"));
914 }
915 Ok(services::set_service_resource_limits(&unit, &assignments))
916 }
917
918 "ListToolboxes" => {
922 let username = validated_username(resolve_username(params)?, "username")?;
923 Ok(toolbox::list_toolboxes(&username))
924 }
925 "CreateToolbox" => {
926 let username = validated_username(resolve_username(params)?, "username")?;
927 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
928 let image = params
929 .get("image")
930 .and_then(|v| v.as_str())
931 .map(|img| validated_safe_arg(img, "image"))
932 .transpose()?;
933 let release = params
934 .get("release")
935 .and_then(|v| v.as_str())
936 .map(|r| validated_safe_arg(r, "release"))
937 .transpose()?;
938 Ok(toolbox::create_toolbox(
939 &username,
940 &name,
941 release.as_deref(),
942 image.as_deref(),
943 ))
944 }
945 "RemoveToolbox" => {
946 let username = validated_username(resolve_username(params)?, "username")?;
947 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
948 Ok(toolbox::remove_toolbox(&username, &name))
949 }
950
951 "GetDateTime" => Ok(identity::get_datetime()),
953 "SetHostname" => {
954 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
955 Ok(identity::set_hostname(&hostname))
956 }
957 "SetTimezone" => {
958 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
959 Ok(identity::set_timezone(&timezone))
960 }
961 "SetLocale" => {
962 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
963 Ok(identity::set_locale(&locale))
964 }
965 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
966
967 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
969
970 "ListProcesses" => Ok(processes::list_processes_spec()),
972 "SignalProcess" => {
973 let pid = params
975 .get("pid")
976 .and_then(|v| {
977 v.as_u64()
978 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
979 })
980 .ok_or(ExecutorError::MissingParam("pid"))?;
981 if pid < 2 || pid > u32::MAX as u64 {
984 return Err(ExecutorError::InvalidParam("pid"));
985 }
986 let signal = validated_kill_signal(
987 params
988 .get("signal")
989 .and_then(|v| v.as_str())
990 .unwrap_or("TERM"),
991 )?;
992 Ok(processes::signal_process(pid as u32, signal))
993 }
994
995 "GetJournalLog" => {
997 let unit = optional_validated(params, "unit", validated_unit_name)?;
998 let priority = optional_validated(params, "priority", validated_journal_priority)?;
999 let since = optional_validated(params, "since", validated_journal_time)?;
1000 let until = optional_validated(params, "until", validated_journal_time)?;
1001 let grep = optional_validated(params, "grep", validated_journal_grep)?;
1002 let lines = params
1005 .get("lines")
1006 .and_then(|v| v.as_u64())
1007 .unwrap_or(100)
1008 .clamp(1, MAX_JOURNAL_LINES) as u32;
1009 let boot = params
1010 .get("boot")
1011 .and_then(|v| v.as_bool())
1012 .unwrap_or(false);
1013 let kernel = params
1014 .get("kernel")
1015 .and_then(|v| v.as_bool())
1016 .unwrap_or(false);
1017 Ok(journald::get_journal_log(&journald::JournalQuery {
1018 lines,
1019 unit: unit.as_deref(),
1020 priority: priority.as_deref(),
1021 boot,
1022 kernel,
1023 since: since.as_deref(),
1024 until: until.as_deref(),
1025 grep: grep.as_deref(),
1026 }))
1027 }
1028 "VacuumJournal" => {
1029 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1031 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1032 match (size_mb, retain_days) {
1033 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1034 Ok(journald::vacuum_journal_by_size(mb as u32))
1035 }
1036 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1037 Ok(journald::vacuum_journal_by_time(days as u32))
1038 }
1039 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1040 _ => Err(ExecutorError::InvalidParam("size_mb")),
1042 }
1043 }
1044
1045 "GetLvmReport" => Ok(lvm::get_lvm_report()),
1047 "ExtendLogicalVolume" => {
1048 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1049 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1050 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1051 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1052 }
1053 "CreateLogicalVolume" => {
1054 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1055 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1056 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1057 Ok(lvm::create_logical_volume(&vg, &name, &size))
1058 }
1059 "CreateLvSnapshot" => {
1060 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1061 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1062 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1063 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1064 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1065 }
1066
1067 "GetSysctl" => {
1069 let key = optional_validated(params, "key", validated_sysctl_key)?;
1071 Ok(sysctl::get_sysctl(key.as_deref()))
1072 }
1073 "SetSysctl" => {
1074 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1075 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1076 Ok(sysctl::set_sysctl(&key, &value))
1077 }
1078
1079 "GetMounts" => Ok(mounts::get_mounts()),
1081 "AddMount" => {
1082 let device = validated_mount_device(require_str(params, "device")?, "device")?;
1083 let mountpoint =
1084 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1085 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1086 let options = optional_validated(params, "options", validated_mount_options)?;
1087 Ok(mounts::add_mount(
1088 &device,
1089 &mountpoint,
1090 &fstype,
1091 options.as_deref(),
1092 ))
1093 }
1094 "RemoveMount" => {
1095 let mountpoint =
1096 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1097 Ok(mounts::remove_mount(&mountpoint))
1098 }
1099 "AddSwap" => {
1100 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1101 let size_mb = require_u32(params, "size_mb")?;
1102 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1104 return Err(ExecutorError::InvalidParam("size_mb"));
1105 }
1106 Ok(mounts::add_swap(&file, size_mb))
1107 }
1108 "RemoveSwap" => {
1109 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1110 Ok(mounts::remove_swap(&file))
1111 }
1112
1113 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1115 "GrantSudoAccess" => {
1116 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1117 let user = validated_username(require_str(params, "user")?, "user")?;
1118 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1119 let runas = match params
1121 .get("runas")
1122 .and_then(|v| v.as_str())
1123 .filter(|s| !s.is_empty())
1124 {
1125 Some("ALL") => Some("ALL".to_string()),
1126 Some(u) => Some(validated_username(u, "runas")?),
1127 None => None,
1128 };
1129 let nopasswd = params
1130 .get("nopasswd")
1131 .and_then(|v| v.as_bool())
1132 .unwrap_or(false);
1133 if commands == "ALL" && nopasswd {
1143 return Err(ExecutorError::InvalidParam("commands"));
1144 }
1145 Ok(sudoers::grant_sudo_access(
1146 &name,
1147 &user,
1148 &commands,
1149 runas.as_deref(),
1150 nopasswd,
1151 ))
1152 }
1153 "RevokeSudoAccess" => {
1154 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1155 Ok(sudoers::revoke_sudo_access(&name))
1156 }
1157
1158 "GetAptPins" => {
1160 let package = optional_validated(params, "package", validated_apt_package)?;
1161 Ok(apt_preferences::get_apt_pins(package.as_deref()))
1162 }
1163 "SetAptPin" => {
1164 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1165 let package = validated_apt_package(require_str(params, "package")?, "package")?;
1166 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1167 let priority = params
1168 .get("priority")
1169 .and_then(|v| v.as_i64())
1170 .ok_or(ExecutorError::MissingParam("priority"))?;
1171 if !(-1..=1000).contains(&priority) {
1172 return Err(ExecutorError::InvalidParam("priority"));
1173 }
1174 Ok(apt_preferences::set_apt_pin(
1175 &name, &package, &pin, priority,
1176 ))
1177 }
1178 "RemoveAptPin" => {
1179 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1180 Ok(apt_preferences::remove_apt_pin(&name))
1181 }
1182
1183 "GetLogrotateStatus" => {
1185 let config = optional_validated(params, "config", validated_log_path)?;
1186 Ok(logging::get_logrotate_status(config.as_deref()))
1187 }
1188 "ConfigureLogRotation" => {
1189 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1190 let path = validated_log_path(require_str(params, "path")?, "path")?;
1191 let frequency = require_str(params, "frequency")?;
1192 if !matches!(frequency, "daily" | "weekly" | "monthly") {
1193 return Err(ExecutorError::InvalidParam("frequency"));
1194 }
1195 let rotate = require_u32(params, "rotate")?;
1196 if rotate > 1000 {
1197 return Err(ExecutorError::InvalidParam("rotate"));
1198 }
1199 let compress = params
1200 .get("compress")
1201 .and_then(|v| v.as_bool())
1202 .unwrap_or(false);
1203 Ok(logging::configure_log_rotation(
1204 &name, &path, frequency, rotate, compress,
1205 ))
1206 }
1207 "RemoveLogRotation" => {
1208 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1209 Ok(logging::remove_log_rotation(&name))
1210 }
1211 "ConfigureRemoteSyslog" => {
1212 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1213 let port = require_u32(params, "port")?;
1214 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1215 return Err(ExecutorError::InvalidParam("port"));
1216 }
1217 let protocol = require_str(params, "protocol")?;
1218 if !matches!(protocol, "tcp" | "udp") {
1219 return Err(ExecutorError::InvalidParam("protocol"));
1220 }
1221 Ok(logging::configure_remote_syslog(
1222 &host,
1223 port as u16,
1224 protocol,
1225 ))
1226 }
1227 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1228
1229 "GetPasswordAging" => {
1231 let user = validated_username(require_str(params, "user")?, "user")?;
1232 Ok(pam::get_password_aging(&user))
1233 }
1234 "SetPasswordAging" => {
1235 let user = validated_username(require_str(params, "user")?, "user")?;
1236 let mut flags = Vec::new();
1238 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1239 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1240 if n > MAX_PASSWORD_AGE_DAYS {
1241 return Err(ExecutorError::InvalidParam(key));
1242 }
1243 flags.push(opt.to_string());
1244 flags.push(n.to_string());
1245 }
1246 }
1247 if flags.is_empty() {
1248 return Err(ExecutorError::MissingParam("max_days"));
1249 }
1250 Ok(pam::set_password_aging(&user, &flags))
1251 }
1252 "SetPasswordPolicy" => {
1253 let mut extra = Vec::new();
1256 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1257 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1258 return Err(ExecutorError::InvalidParam("minlen"));
1259 }
1260 extra.push("--minlen".to_string());
1261 extra.push(n.to_string());
1262 }
1263 for (key, flag) in [
1264 ("dcredit", "--dcredit"),
1265 ("ucredit", "--ucredit"),
1266 ("lcredit", "--lcredit"),
1267 ("ocredit", "--ocredit"),
1268 ] {
1269 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1270 if !(-64..=64).contains(&n) {
1271 return Err(ExecutorError::InvalidParam(key));
1272 }
1273 extra.push(flag.to_string());
1274 extra.push(n.to_string());
1275 }
1276 }
1277 if extra.is_empty() {
1278 return Err(ExecutorError::MissingParam("minlen"));
1279 }
1280 Ok(pam::set_password_policy(&extra))
1281 }
1282 "SetAccountLockout" => {
1283 let mut extra = Vec::new();
1285 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1286 if !(1..=1000).contains(&n) {
1287 return Err(ExecutorError::InvalidParam("deny"));
1288 }
1289 extra.push("--deny".to_string());
1290 extra.push(n.to_string());
1291 }
1292 for (key, flag) in [
1293 ("unlock_time", "--unlock-time"),
1294 ("fail_interval", "--fail-interval"),
1295 ] {
1296 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1297 if n > MAX_LOCKOUT_WINDOW_SECS {
1298 return Err(ExecutorError::InvalidParam(key));
1299 }
1300 extra.push(flag.to_string());
1301 extra.push(n.to_string());
1302 }
1303 }
1304 if extra.is_empty() {
1305 return Err(ExecutorError::MissingParam("deny"));
1306 }
1307 Ok(pam::set_account_lockout(&extra))
1308 }
1309
1310 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1312 "GetHostState" => Ok(system_info::get_host_state()),
1313
1314 "GetFirewallState" => Ok(network::get_firewall_state()),
1316 "GetNetworkStatus" => Ok(network::get_network_status()),
1317 "GetListeningPorts" => Ok(network::get_listening_ports()),
1318 "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1319 "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1320 "ConfigureWifi" => {
1321 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1322 let password = params
1324 .get("password")
1325 .and_then(|v| v.as_str())
1326 .filter(|p| !p.is_empty())
1327 .map(|p| validated_safe_arg(p, "password"))
1328 .transpose()?;
1329 Ok(network::configure_wifi(&ssid, password.as_deref()))
1330 }
1331 "SetDnsServers" => {
1332 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1333 let servers = str_array_or_empty(params, "servers")?;
1334 let validated: Vec<String> = servers
1335 .iter()
1336 .map(|s| validated_safe_arg(s, "servers"))
1337 .collect::<Result<_, _>>()?;
1338 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1339 Ok(network::set_dns_servers(&interface, &refs))
1340 }
1341 "ConfigureFirewall" => {
1342 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1343 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1344 Ok(network::configure_firewall(
1345 &zone,
1346 &service,
1347 require_bool(params, "enabled")?,
1348 ))
1349 }
1350
1351 "ListUsers" => Ok(users::list_users()),
1353 "ListGroups" => Ok(users::list_groups()),
1354 "CreateUser" => {
1355 let username = validated_username(resolve_username(params)?, "username")?;
1356 let shell = params
1357 .get("shell")
1358 .and_then(|v| v.as_str())
1359 .map(|s| validated_safe_arg(s, "shell"))
1360 .transpose()?;
1361 let home = params
1362 .get("home")
1363 .and_then(|v| v.as_str())
1364 .map(|h| validated_home_dir(h, "home"))
1365 .transpose()?;
1366 Ok(users::create_user(
1367 &username,
1368 shell.as_deref(),
1369 home.as_deref(),
1370 ))
1371 }
1372 "DeleteUser" => {
1373 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1374 Ok(users::delete_user(&username))
1375 }
1376 "AddUserToGroup" => {
1377 let username = validated_username(resolve_username(params)?, "username")?;
1378 let group = validated_group(require_str(params, "group")?, "group")?;
1379 Ok(users::add_user_to_group(&username, &group))
1380 }
1381 "RemoveUserFromGroup" => {
1382 let username = validated_username(resolve_username(params)?, "username")?;
1383 let group = validated_group(require_str(params, "group")?, "group")?;
1384 Ok(users::remove_user_from_group(&username, &group))
1385 }
1386 "CreateGroup" => {
1387 let group = validated_group(require_str(params, "group")?, "group")?;
1388 let system = params
1389 .get("system")
1390 .and_then(|v| v.as_bool())
1391 .unwrap_or(false);
1392 Ok(users::create_group(&group, system))
1393 }
1394 "DeleteGroup" => {
1395 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1396 Ok(users::delete_group(&group))
1397 }
1398 "LockUserAccount" => {
1399 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1400 Ok(users::lock_user_account(&username))
1401 }
1402 "UnlockUserAccount" => {
1403 let username = validated_username(resolve_username(params)?, "username")?;
1404 Ok(users::unlock_user_account(&username))
1405 }
1406
1407 "GetAuthorizedKeys" => {
1409 let username = validated_username(resolve_username(params)?, "username")?;
1410 Ok(ssh::get_authorized_keys(&username))
1411 }
1412 "AddAuthorizedKey" => {
1413 let username = validated_username(resolve_username(params)?, "username")?;
1414 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1415 Ok(ssh::add_authorized_key(&username, &public_key))
1416 }
1417 "RemoveAuthorizedKey" => {
1418 let username = validated_username(resolve_username(params)?, "username")?;
1419 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1420 Ok(ssh::remove_authorized_key(&username, &public_key))
1421 }
1422 "SetSshdOption" => {
1423 let option = require_str(params, "option")?;
1427 let value = require_str(params, "value")?;
1428 let allowed_values: &[&str] = match option {
1429 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1430 "PasswordAuthentication"
1431 | "PubkeyAuthentication"
1432 | "X11Forwarding"
1433 | "PermitEmptyPasswords" => &["yes", "no"],
1434 _ => return Err(ExecutorError::InvalidParam("option")),
1435 };
1436 if !allowed_values.contains(&value) {
1437 return Err(ExecutorError::InvalidParam("value"));
1438 }
1439 Ok(ssh::set_sshd_option(option, value))
1440 }
1441
1442 "AptUpdate" => Ok(apt::apt_update()),
1444 "AptUpgrade" => Ok(apt::apt_upgrade()),
1445 "AptInstall" => {
1446 let package = validated_install_package(require_str(params, "package")?, "package")?;
1447 Ok(apt::apt_install(&package))
1448 }
1449 "AptRemove" => {
1450 let package = validated_install_package(require_str(params, "package")?, "package")?;
1451 Ok(apt::apt_remove(&package))
1452 }
1453 "AptPurge" => {
1454 let package = validated_install_package(require_str(params, "package")?, "package")?;
1455 Ok(apt::apt_purge(&package))
1456 }
1457 "AptAutoremove" => Ok(apt::apt_autoremove()),
1458 "AptHold" => {
1459 let package = validated_install_package(require_str(params, "package")?, "package")?;
1460 Ok(apt::apt_hold(&package))
1461 }
1462 "AptUnhold" => {
1463 let package = validated_install_package(require_str(params, "package")?, "package")?;
1464 Ok(apt::apt_unhold(&package))
1465 }
1466 "AptSearch" => {
1467 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1468 Ok(apt::apt_search(&term))
1469 }
1470 "AptListInstalled" => Ok(apt::apt_list_installed()),
1471 "AptShow" => {
1472 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1473 Ok(apt::apt_show(&package))
1474 }
1475 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1476 "AptHistoryList" => Ok(apt::apt_history_list()),
1477 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1478 params, "enabled",
1479 )?)),
1480
1481 "AddPpa" => {
1483 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1484 Ok(ppa::add_ppa(&name))
1485 }
1486 "RemovePpa" => {
1487 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1488 Ok(ppa::remove_ppa(&name))
1489 }
1490
1491 "SnapInstall" => {
1493 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1494 let channel = params
1495 .get("channel")
1496 .and_then(|v| v.as_str())
1497 .filter(|s| !s.is_empty())
1498 .map(|s| validated_safe_arg(s, "channel"))
1499 .transpose()?;
1500 let auto_update = params
1501 .get("auto_update")
1502 .and_then(|v| v.as_bool())
1503 .unwrap_or(false);
1504 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1505 snap::SnapInstallError::InvalidArg { param, .. } => {
1506 ExecutorError::InvalidParam(param)
1507 }
1508 })
1509 }
1510 "SnapRemove" => {
1511 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1512 Ok(snap::snap_remove(&name))
1513 }
1514 "SnapRefresh" => {
1515 let name = params
1516 .get("name")
1517 .and_then(|v| v.as_str())
1518 .filter(|s| !s.is_empty())
1519 .map(|s| validated_safe_arg(s, "name"))
1520 .transpose()?;
1521 Ok(snap::snap_refresh(name.as_deref()))
1522 }
1523 "SnapHold" => {
1524 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1525 Ok(snap::snap_hold(&name))
1526 }
1527 "SnapUnhold" => {
1528 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1529 Ok(snap::snap_unhold(&name))
1530 }
1531 "SnapList" => Ok(snap::snap_list()),
1532 "SnapInfo" => {
1533 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1534 Ok(snap::snap_info(&name))
1535 }
1536 "SnapRevert" => {
1537 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1538 Ok(snap::snap_revert(&name))
1539 }
1540 "SnapClassicInstall" => {
1541 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1542 Ok(snap::snap_classic_install(&name))
1543 }
1544
1545 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1547 "GrubSetKargs" => {
1548 let append: Vec<String> = params
1549 .get("append")
1550 .and_then(|v| v.as_array())
1551 .map(|arr| {
1552 arr.iter()
1553 .filter_map(|v| v.as_str())
1554 .map(String::from)
1555 .collect()
1556 })
1557 .unwrap_or_default();
1558 let delete: Vec<String> = params
1559 .get("delete")
1560 .and_then(|v| v.as_array())
1561 .map(|arr| {
1562 arr.iter()
1563 .filter_map(|v| v.as_str())
1564 .map(String::from)
1565 .collect()
1566 })
1567 .unwrap_or_default();
1568 for a in &append {
1577 validated_safe_arg(a, "append")?;
1578 validated_safe_kernel_arg(a, "append")?;
1579 }
1580 for d in &delete {
1581 validated_safe_arg(d, "delete")?;
1582 validated_safe_kernel_arg_removal(d, "delete")?;
1583 }
1584 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1587 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1588 grub::grub_set_kargs(&append_refs, &delete_refs)
1589 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1590 }
1591
1592 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1594
1595 "UfwEnable" => Ok(ufw::ufw_enable()),
1597 "UfwDisable" => Ok(ufw::ufw_disable()),
1598 "UfwAllow" => {
1599 let port_or_service = validated_port_or_service(
1600 require_str(params, "port_or_service")?,
1601 "port_or_service",
1602 )?;
1603 Ok(ufw::ufw_allow(&port_or_service))
1604 }
1605 "UfwDeny" => {
1606 let port_or_service = validated_port_or_service(
1607 require_str(params, "port_or_service")?,
1608 "port_or_service",
1609 )?;
1610 Ok(ufw::ufw_deny(&port_or_service))
1611 }
1612 "UfwReset" => Ok(ufw::ufw_reset()),
1613 "UfwStatus" => {
1614 let numbered = match params.get("numbered") {
1615 None => false,
1616 Some(value) => value
1617 .as_bool()
1618 .ok_or(ExecutorError::InvalidParam("numbered"))?,
1619 };
1620 Ok(ufw::ufw_status_with_numbered(numbered))
1621 }
1622
1623 "DistroboxList" => Ok(distrobox::distrobox_list()),
1625 "DistroboxCreate" => {
1626 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1627 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1628 Ok(distrobox::distrobox_create(&name, &image))
1629 }
1630 "DistroboxRemove" => {
1631 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1632 Ok(distrobox::distrobox_remove(&name))
1633 }
1634
1635 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1637 "NetplanApply" => Ok(netplan::netplan_apply()),
1638 "NetplanSet" => {
1639 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1640 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1641 Ok(netplan::netplan_set(&key, &value))
1642 }
1643 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1644
1645 "UfwDeleteRule" => {
1647 let rule_number = require_positive_u32(params, "rule_number")?;
1648 ufw::ufw_delete_rule(rule_number)
1649 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1650 }
1651 "UfwLimit" => {
1652 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1653 Ok(ufw::ufw_limit(&target))
1654 }
1655
1656 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1658 "ProAttach" => {
1659 let token = require_str(params, "token")?;
1661 let token = validated_safe_arg(token, "token")?;
1663 Ok(ubuntu_pro::pro_attach(&token))
1664 }
1665 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1666 "EnableProService" => {
1667 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1668 Ok(ubuntu_pro::enable_pro_service(&service))
1669 }
1670 "DisableProService" => {
1671 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1672 Ok(ubuntu_pro::disable_pro_service(&service))
1673 }
1674
1675 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1677
1678 "MultipassList" => Ok(multipass::multipass_list()),
1680
1681 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1683
1684 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1686 "ResolvectlSetDns" => {
1687 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1688 let raw_servers: Vec<String> = params
1689 .get("servers")
1690 .and_then(|v| v.as_array())
1691 .map(|arr| {
1692 arr.iter()
1693 .filter_map(|v| v.as_str())
1694 .map(String::from)
1695 .collect()
1696 })
1697 .unwrap_or_default();
1698 if raw_servers.is_empty() {
1699 return Err(ExecutorError::MissingParam("servers"));
1700 }
1701 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1705 for s in &raw_servers {
1706 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1707 param: "servers",
1708 value: s.clone(),
1709 })?;
1710 parsed_servers.push(addr);
1711 }
1712 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1713 }
1714
1715 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1717 "AppArmorEnforce" => {
1718 let profile_path =
1719 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1720 Ok(apparmor::apparmor_enforce(&profile_path))
1721 }
1722 "AppArmorComplain" => {
1723 let profile_path =
1724 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1725 Ok(apparmor::apparmor_complain(&profile_path))
1726 }
1727
1728 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1730
1731 "UbuntuInstallFlatpak" => {
1733 let username = validated_username(resolve_username(params)?, "username")?;
1734 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1735 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1736 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1737 }
1738 "UbuntuRemoveFlatpak" => {
1739 let username = validated_username(resolve_username(params)?, "username")?;
1740 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1741 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1742 }
1743 "UbuntuUpdateFlatpak" => {
1744 let username = validated_username(resolve_username(params)?, "username")?;
1745 let app_id = params
1746 .get("app_id")
1747 .and_then(|v| v.as_str())
1748 .filter(|s| !s.is_empty())
1749 .map(|s| validated_safe_arg(s, "app_id"))
1750 .transpose()?;
1751 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1752 }
1753 "UbuntuListFlatpaks" => {
1754 let username = validated_username(resolve_username(params)?, "username")?;
1755 Ok(flatpak::ubuntu_list_flatpaks(&username))
1756 }
1757
1758 "Fail2banStatus" => {
1760 let jail = params
1761 .get("jail")
1762 .and_then(|v| v.as_str())
1763 .filter(|s| !s.is_empty())
1764 .map(|s| validated_safe_arg(s, "jail"))
1765 .transpose()?;
1766 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1767 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1768 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1769 })
1770 }
1771 "Fail2banBanIp" => {
1772 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1773 let ip = require_str(params, "ip")?;
1774 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1775 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1776 param: "ip",
1777 value: v,
1778 },
1779 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1780 })
1781 }
1782 "Fail2banUnbanIp" => {
1783 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1784 let ip = require_str(params, "ip")?;
1785 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1786 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1787 param: "ip",
1788 value: v,
1789 },
1790 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1791 })
1792 }
1793 "ConfigureFail2banJail" => {
1794 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1795 let mut extra = Vec::new();
1796 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1797 extra.push("--enabled".to_string());
1798 extra.push(if n { "true" } else { "false" }.to_string());
1799 }
1800 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1801 if !(1..=100).contains(&n) {
1802 return Err(ExecutorError::InvalidParam("maxretry"));
1803 }
1804 extra.push("--maxretry".to_string());
1805 extra.push(n.to_string());
1806 }
1807 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1808 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1809 if n > MAX_FAIL2BAN_WINDOW_SECS {
1810 return Err(ExecutorError::InvalidParam(key));
1811 }
1812 extra.push(flag.to_string());
1813 extra.push(n.to_string());
1814 }
1815 }
1816 if extra.is_empty() {
1817 return Err(ExecutorError::MissingParam("maxretry"));
1818 }
1819 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
1820 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
1821 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
1822 })
1823 }
1824
1825 "GetAuditRules" => Ok(auditd::get_audit_rules()),
1827 "AddAuditRule" => {
1828 let path = validated_audit_path(require_str(params, "path")?, "path")?;
1829 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1830 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1831 Ok(auditd::add_audit_rule(&path, &perms, &key))
1832 }
1833 "RemoveAuditRule" => {
1834 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1835 Ok(auditd::remove_audit_rule(&key))
1836 }
1837
1838 "GetCertificates" => Ok(certbot::get_certificates()),
1840 "ObtainCertificate" => {
1841 let mut domains = Vec::new();
1843 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1844 for d in arr {
1845 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1846 domains.push(validated_domain(s, "domains")?);
1847 }
1848 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1849 domains.push(validated_domain(d, "domain")?);
1850 }
1851 if domains.is_empty() {
1852 return Err(ExecutorError::MissingParam("domains"));
1853 }
1854 let email = validated_email(require_str(params, "email")?, "email")?;
1855 let challenge = params
1856 .get("challenge")
1857 .and_then(|v| v.as_str())
1858 .unwrap_or("standalone");
1859 if !matches!(challenge, "standalone" | "nginx" | "apache") {
1860 return Err(ExecutorError::InvalidParam("challenge"));
1861 }
1862 Ok(certbot::obtain_certificate(&domains, &email, challenge))
1863 }
1864 "RenewCertificates" => Ok(certbot::renew_certificates()),
1865
1866 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1867 }
1868}
1869
1870pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1876 match &spec.mechanism {
1877 ActionMechanism::Command { program, args } => {
1878 let mut child = tokio::process::Command::new(program)
1882 .args(args)
1883 .stdin(Stdio::null())
1884 .process_group(0)
1887 .stdout(Stdio::piped())
1888 .stderr(Stdio::piped())
1889 .spawn()?;
1890
1891 let stdout_h = child.stdout.take().expect("stdout was piped");
1896 let stderr_h = child.stderr.take().expect("stderr was piped");
1897 let stdout_task = tokio::spawn(async move {
1898 let mut buf = Vec::new();
1899 BufReader::new(stdout_h)
1900 .read_to_end(&mut buf)
1901 .await
1902 .map(|_| buf)
1903 });
1904 let stderr_task = tokio::spawn(async move {
1905 let mut buf = Vec::new();
1906 BufReader::new(stderr_h)
1907 .read_to_end(&mut buf)
1908 .await
1909 .map(|_| buf)
1910 });
1911
1912 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
1913 Ok(Ok(status)) => status,
1914 Ok(Err(err)) => {
1918 return Err(kill_and_reap(
1919 &mut child,
1920 program,
1921 args,
1922 StopCause::Unreadable(err),
1923 )
1924 .await)
1925 }
1926 Err(_) => {
1927 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
1928 }
1929 };
1930 let join =
1931 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
1932 let stdout = stdout_task.await.map_err(join)??;
1933 let stderr = stderr_task.await.map_err(join)??;
1934
1935 Ok(ExecutionOutput {
1936 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1937 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1938 exit_code: status.code().unwrap_or(-1),
1939 })
1940 }
1941 ActionMechanism::FileScan { path } => {
1942 let mut entries = tokio::fs::read_dir(path).await?;
1943 let mut names = Vec::new();
1944 while let Some(entry) = entries.next_entry().await? {
1945 names.push(entry.file_name().to_string_lossy().into_owned());
1946 }
1947 names.sort();
1948 Ok(ExecutionOutput {
1949 stdout: names.join("\n"),
1950 stderr: String::new(),
1951 exit_code: 0,
1952 })
1953 }
1954 ActionMechanism::FileWrite { path, content } => {
1955 if let Some(parent) = std::path::Path::new(path).parent() {
1956 tokio::fs::create_dir_all(parent).await?;
1957 }
1958 tokio::fs::write(path, content).await?;
1959 Ok(ExecutionOutput {
1960 stdout: String::new(),
1961 stderr: String::new(),
1962 exit_code: 0,
1963 })
1964 }
1965 ActionMechanism::FilePatch {
1966 path,
1967 search,
1968 replace,
1969 } => {
1970 let content = tokio::fs::read_to_string(path).await?;
1971 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1972 if patched == content && !search.is_empty() {
1973 return Ok(ExecutionOutput {
1974 stdout: String::new(),
1975 stderr: format!("search string not found in file: {}", path),
1976 exit_code: 1,
1977 });
1978 }
1979 tokio::fs::write(path, patched).await?;
1980 Ok(ExecutionOutput {
1981 stdout: String::new(),
1982 stderr: String::new(),
1983 exit_code: 0,
1984 })
1985 }
1986 ActionMechanism::FileDelete { path } => {
1987 tokio::fs::remove_file(path).await?;
1988 Ok(ExecutionOutput {
1989 stdout: String::new(),
1990 stderr: String::new(),
1991 exit_code: 0,
1992 })
1993 }
1994 }
1995}
1996
1997fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
1998 match params.get(key) {
1999 None => Err(ExecutorError::MissingParam(key)),
2000 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
2001 }
2002}
2003
2004fn optional_validated<F>(
2008 params: &Value,
2009 key: &'static str,
2010 validator: F,
2011) -> Result<Option<String>, ExecutorError>
2012where
2013 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2014{
2015 match params
2016 .get(key)
2017 .and_then(|v| v.as_str())
2018 .filter(|s| !s.is_empty())
2019 {
2020 Some(s) => Ok(Some(validator(s, key)?)),
2021 None => Ok(None),
2022 }
2023}
2024
2025fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2035 params
2036 .get("username")
2037 .or_else(|| params.get("user"))
2038 .ok_or(ExecutorError::MissingParam("username"))
2039 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2040}
2041
2042fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2046 let id = require_str(params, "repo_id")?;
2047 let valid = !id.is_empty()
2048 && id
2049 .chars()
2050 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2051 if valid {
2052 Ok(id)
2053 } else {
2054 Err(ExecutorError::InvalidParam("repo_id"))
2055 }
2056}
2057
2058fn validated_no_newline<'a>(
2061 params: &'a Value,
2062 key: &'static str,
2063) -> Result<&'a str, ExecutorError> {
2064 let val = require_str(params, key)?;
2065 if val.contains('\n') || val.contains('\r') {
2066 Err(ExecutorError::InvalidParam(key))
2067 } else {
2068 Ok(val)
2069 }
2070}
2071
2072fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2076 const MAX_LEN: usize = 8192;
2077 const ALLOWED_PREFIXES: &[&str] = &[
2078 "ssh-rsa",
2079 "ssh-ed25519",
2080 "ssh-ed25519-sk",
2081 "ecdsa-sha2-nistp256",
2082 "ecdsa-sha2-nistp384",
2083 "ecdsa-sha2-nistp521",
2084 "sk-ssh-ed25519",
2085 "sk-ecdsa-sha2-nistp256",
2086 ];
2087
2088 if s.is_empty() || s.len() > MAX_LEN {
2089 return Err(ExecutorError::InvalidParam("public_key"));
2090 }
2091 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2092 return Err(ExecutorError::InvalidParam("public_key"));
2093 }
2094 if s.chars().any(|c| {
2109 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2110 || !c.is_ascii()
2111 || c.is_ascii_control()
2112 }) {
2113 return Err(ExecutorError::InvalidParam("public_key"));
2114 }
2115 Ok(s.to_string())
2116}
2117
2118fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2119 match params.get(key) {
2120 None => Err(ExecutorError::MissingParam(key)),
2121 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2122 }
2123}
2124
2125fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2126 match params.get(key) {
2127 None => Err(ExecutorError::MissingParam(key)),
2128 Some(v) => {
2129 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2130 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2131 }
2132 }
2133}
2134
2135fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2139 let n = require_u32(params, key)?;
2140 if n == 0 {
2141 return Err(ExecutorError::InvalidParam(key));
2142 }
2143 Ok(n)
2144}
2145
2146fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2150 match params.get(key) {
2151 None | Some(Value::Null) => Ok(vec![]),
2152 Some(Value::Array(arr)) => arr
2153 .iter()
2154 .map(|v| {
2155 v.as_str()
2156 .map(String::from)
2157 .ok_or(ExecutorError::InvalidParam(key))
2158 })
2159 .collect(),
2160 _ => Err(ExecutorError::InvalidParam(key)),
2161 }
2162}
2163
2164fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2199 const BLOCKED_PREFIXES: &[&str] = &[
2200 "init=",
2201 "selinux=0",
2202 "enforcing=0",
2203 "security=",
2204 "module_blacklist=",
2205 "apparmor=0",
2206 "mitigations=off",
2207 "lockdown=",
2208 "pti=off",
2209 "systemd.debug_shell",
2218 "systemd.debug-shell",
2219 "rd.systemd.debug_shell",
2220 "rd.systemd.debug-shell",
2221 ];
2222 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2223 use crate::actions::validate::ROOT_SHELL_UNITS as BLOCKED_UNIT_PREFIXES;
2229
2230 let lower = arg.to_lowercase();
2231 let base = lower.split('=').next().unwrap_or(&lower);
2233
2234 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2235 return Err(ExecutorError::InvalidParam(param));
2236 }
2237 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2238 return Err(ExecutorError::InvalidParam(param));
2239 }
2240 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2242 if BLOCKED_UNIT_PREFIXES
2243 .iter()
2244 .any(|u| unit_val.starts_with(u))
2245 {
2246 return Err(ExecutorError::InvalidParam(param));
2247 }
2248 }
2249 if BLOCKED_EXACT.contains(&base) {
2251 return Err(ExecutorError::InvalidParam(param));
2252 }
2253 Ok(())
2254}
2255
2256fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2272 const WEAKENING: &[&str] = &[
2275 "selinux=0",
2276 "enforcing=0",
2277 "apparmor=0",
2278 "mitigations=off",
2279 "pti=off",
2280 "lockdown=none",
2281 "debugfs=on",
2282 "iommu=off",
2283 "intel_iommu=off",
2284 "module.sig_enforce=0",
2285 "init_on_alloc=0",
2286 "init_on_free=0",
2287 "randomize_kstack_offset=off",
2288 "page_alloc.shuffle=0",
2289 ];
2290 const PROTECTIVE_PREFIXES: &[&str] = &[
2291 "lockdown=",
2292 "module.sig_enforce=",
2293 "security=",
2294 "selinux=",
2295 "enforcing=",
2296 "apparmor=",
2297 "pti=",
2298 "mitigations=",
2299 "init_on_alloc=",
2300 "init_on_free=",
2301 "page_alloc.shuffle=",
2302 "randomize_kstack_offset=",
2303 "slub_debug=",
2304 "page_poison=",
2305 "vsyscall=",
2306 "debugfs=",
2307 "iommu=",
2308 "intel_iommu=",
2309 "amd_iommu=",
2310 ];
2311 const PROTECTIVE_EXACT: &[&str] = &["slab_nomerge", "nosmt", "kaslr"];
2312
2313 let lower = arg.to_lowercase();
2314 if WEAKENING.contains(&lower.as_str()) {
2315 return Ok(());
2316 }
2317 if PROTECTIVE_PREFIXES.iter().any(|p| lower.starts_with(p))
2320 || PROTECTIVE_EXACT.contains(&lower.as_str())
2321 {
2322 return Err(ExecutorError::InvalidParam(param));
2323 }
2324 Ok(())
2328}
2329
2330fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2338 let normalized = s.trim().to_ascii_uppercase();
2339 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2340 "TERM" => Ok("TERM"),
2341 "KILL" => Ok("KILL"),
2342 "HUP" => Ok("HUP"),
2343 "INT" => Ok("INT"),
2344 _ => Err(ExecutorError::InvalidParam("signal")),
2345 }
2346}
2347
2348pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2358 match action_name {
2359 "UpdateSystem"
2360 | "InstallPackages"
2361 | "RemovePackages"
2362 | "RebaseSystem"
2363 | "SetKernelArguments"
2364 | "AddLayeredPackage"
2365 | "RemoveLayeredPackage"
2366 | "ReplaceLayeredPackage"
2367 | "ResetLayeredPackageOverride"
2368 | "RemoveBasePackage" => Some(ActionSpec {
2369 action_name: "RollbackDeployment",
2370 mechanism: ActionMechanism::Command {
2371 program: "rpm-ostree",
2372 args: vec!["rollback".to_string()],
2373 },
2374 risk_level: sysknife_types::RiskLevel::High,
2375 reboot_required: true,
2376 rollback_available: false,
2377 }),
2378 _ => None,
2379 }
2380}
2381
2382#[cfg(test)]
2383mod tests {
2384 use super::*;
2385 use serde_json::json;
2386 use sysknife_types::RiskLevel;
2387 use tempfile::tempdir;
2388
2389 #[test]
2392 fn build_spec_no_params_for_get_system_state() {
2393 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2394 assert_eq!(spec.action_name, "GetSystemState");
2395 assert_eq!(spec.risk_level, RiskLevel::Low);
2396 }
2397
2398 #[test]
2399 fn build_spec_get_datetime_is_low_risk() {
2400 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2401 assert_eq!(spec.action_name, "GetDateTime");
2402 assert_eq!(spec.risk_level, RiskLevel::Low);
2403 assert!(!spec.reboot_required);
2404 }
2405
2406 #[test]
2407 fn build_spec_unknown_action_returns_error() {
2408 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2409 assert!(
2410 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2411 "expected UnknownAction, got: {err}"
2412 );
2413 }
2414
2415 #[test]
2416 fn build_spec_missing_param_for_install_flatpak() {
2417 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2419 assert!(
2420 matches!(err, ExecutorError::MissingParam("username")),
2421 "expected MissingParam(username), got: {err}"
2422 );
2423 }
2424
2425 #[test]
2429 fn build_spec_flatpak_accepts_user_alias() {
2430 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2431 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2432 }
2433
2434 #[test]
2436 fn build_spec_resolve_username_prefers_explicit_username() {
2437 let spec = build_action_spec(
2438 "ListInstalledFlatpaks",
2439 &json!({ "username": "alice", "user": "bob" }),
2440 )
2441 .unwrap();
2442 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2444 }
2445
2446 #[test]
2449 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2450 let spec = build_action_spec(
2451 "InstallFlatpak",
2452 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2453 )
2454 .unwrap();
2455 assert_eq!(
2456 spec.mechanism,
2457 ActionMechanism::Command {
2458 program: "sudo",
2459 args: vec![
2460 "/usr/lib/sysknife/action-steps".to_string(),
2461 "flatpak".to_string(),
2462 "alice".to_string(),
2463 "install".to_string(),
2464 "--user".to_string(),
2465 "-y".to_string(),
2466 "flathub".to_string(),
2467 "org.mozilla.firefox".to_string(),
2468 ],
2469 }
2470 );
2471 }
2472
2473 #[test]
2474 fn build_spec_install_flatpak_injects_app_and_remote() {
2475 let spec = build_action_spec(
2476 "InstallFlatpak",
2477 &json!({
2478 "username": "alice",
2479 "app_id": "org.mozilla.firefox",
2480 "remote": "flathub"
2481 }),
2482 )
2483 .unwrap();
2484 assert_eq!(spec.action_name, "InstallFlatpak");
2485 assert_eq!(
2486 spec.mechanism,
2487 ActionMechanism::Command {
2488 program: "sudo",
2489 args: vec![
2490 "/usr/lib/sysknife/action-steps".to_string(),
2491 "flatpak".to_string(),
2492 "alice".to_string(),
2493 "install".to_string(),
2494 "--user".to_string(),
2495 "-y".to_string(),
2496 "flathub".to_string(),
2497 "org.mozilla.firefox".to_string(),
2498 ],
2499 }
2500 );
2501 }
2502
2503 #[test]
2504 fn build_spec_pin_deployment_injects_index() {
2505 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2506 assert_eq!(
2507 spec.mechanism,
2508 ActionMechanism::Command {
2509 program: "sudo",
2510 args: vec![
2511 "ostree".to_string(),
2512 "admin".to_string(),
2513 "pin".to_string(),
2514 "1".to_string()
2515 ],
2516 }
2517 );
2518 }
2519
2520 #[test]
2521 fn build_spec_unpin_deployment_includes_unpin_flag() {
2522 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2523 assert_eq!(
2524 spec.mechanism,
2525 ActionMechanism::Command {
2526 program: "sudo",
2527 args: vec![
2528 "ostree".to_string(),
2529 "admin".to_string(),
2530 "pin".to_string(),
2531 "--unpin".to_string(),
2532 "2".to_string(),
2533 ],
2534 }
2535 );
2536 }
2537
2538 #[test]
2539 fn require_u32_rejects_overflow() {
2540 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2541 assert!(
2542 matches!(err, ExecutorError::InvalidParam("index")),
2543 "expected InvalidParam(index), got: {err}"
2544 );
2545 }
2546
2547 #[test]
2548 fn build_spec_rebase_system_injects_target_ref() {
2549 let spec = build_action_spec(
2550 "RebaseSystem",
2551 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
2552 )
2553 .unwrap();
2554 assert_eq!(
2555 spec.mechanism,
2556 ActionMechanism::Command {
2557 program: "sudo",
2558 args: vec![
2559 "rpm-ostree".to_string(),
2560 "rebase".to_string(),
2561 "fedora/41/x86_64/silverblue".to_string(),
2562 ],
2563 }
2564 );
2565 }
2566
2567 #[test]
2568 fn build_spec_set_kernel_arguments_appends_and_deletes() {
2569 let spec = build_action_spec(
2570 "SetKernelArguments",
2571 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
2572 )
2573 .unwrap();
2574 assert_eq!(
2575 spec.mechanism,
2576 ActionMechanism::Command {
2577 program: "sudo",
2578 args: vec![
2579 "rpm-ostree".to_string(),
2580 "kargs".to_string(),
2581 "--append=nomodeset".to_string(),
2582 "--delete=quiet".to_string(),
2583 ],
2584 }
2585 );
2586 }
2587
2588 #[test]
2589 fn build_spec_set_kernel_arguments_with_empty_arrays() {
2590 let spec =
2591 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
2592 assert_eq!(
2593 spec.mechanism,
2594 ActionMechanism::Command {
2595 program: "sudo",
2596 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2597 }
2598 );
2599 }
2600
2601 #[test]
2602 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
2603 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
2604 assert_eq!(
2605 spec.mechanism,
2606 ActionMechanism::Command {
2607 program: "sudo",
2608 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2609 }
2610 );
2611 }
2612
2613 #[test]
2616 fn delete_user_rejects_critical_account() {
2617 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
2618 assert!(
2619 matches!(err, ExecutorError::InvalidParam("username")),
2620 "expected InvalidParam(username), got: {err}"
2621 );
2622 }
2623
2624 #[test]
2625 fn delete_group_rejects_critical_group() {
2626 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
2627 assert!(
2628 matches!(err, ExecutorError::InvalidParam("group")),
2629 "expected InvalidParam(group), got: {err}"
2630 );
2631 }
2632
2633 #[test]
2634 fn lock_user_account_rejects_critical_account() {
2635 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
2636 assert!(
2637 matches!(err, ExecutorError::InvalidParam("username")),
2638 "expected InvalidParam(username), got: {err}"
2639 );
2640 }
2641
2642 #[test]
2643 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
2644 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
2645 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
2646 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
2647 }
2648
2649 #[test]
2652 fn build_spec_add_package_repository_rejects_path_traversal() {
2653 let err = build_action_spec(
2654 "AddPackageRepository",
2655 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
2656 )
2657 .unwrap_err();
2658 assert!(
2659 matches!(err, ExecutorError::InvalidParam("repo_id")),
2660 "expected InvalidParam(repo_id), got: {err}"
2661 );
2662 }
2663
2664 #[test]
2665 fn build_spec_add_package_repository_rejects_newline_in_url() {
2666 let err = build_action_spec(
2667 "AddPackageRepository",
2668 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2669 )
2670 .unwrap_err();
2671 assert!(
2672 matches!(err, ExecutorError::InvalidParam("repo_url")),
2673 "expected InvalidParam(repo_url), got: {err}"
2674 );
2675 }
2676
2677 #[test]
2678 fn build_spec_add_package_repository_accepts_valid_repo_id() {
2679 let spec = build_action_spec(
2680 "AddPackageRepository",
2681 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2682 )
2683 .unwrap();
2684 assert_eq!(spec.action_name, "AddPackageRepository");
2685 }
2686
2687 #[test]
2688 fn build_spec_remove_package_repository_rejects_path_traversal() {
2689 let err = build_action_spec(
2690 "RemovePackageRepository",
2691 &json!({ "repo_id": "../../etc/passwd" }),
2692 )
2693 .unwrap_err();
2694 assert!(
2695 matches!(err, ExecutorError::InvalidParam("repo_id")),
2696 "expected InvalidParam(repo_id), got: {err}"
2697 );
2698 }
2699
2700 #[tokio::test]
2701 async fn execute_spec_command_captures_stdout() {
2702 let spec = ActionSpec {
2703 action_name: "GetSystemState",
2704 mechanism: ActionMechanism::Command {
2705 program: "echo",
2706 args: vec!["hello".to_string()],
2707 },
2708 risk_level: RiskLevel::Low,
2709 reboot_required: false,
2710 rollback_available: false,
2711 };
2712 let out = execute_spec(&spec).await.unwrap();
2713 assert_eq!(out.stdout.trim(), "hello");
2714 assert_eq!(out.exit_code, 0);
2715 }
2716
2717 #[test]
2725 fn action_not_stopped_names_the_group_and_the_timeout() {
2726 let err = ExecutorError::ActionNotStopped {
2727 program: "rpm-ostree".to_string(),
2728 pgid: 4242,
2729 timeout_secs: 7200,
2730 };
2731 let msg = err.to_string();
2732 assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
2733 assert!(msg.contains("7200"), "must name the timeout: {msg}");
2734 assert!(
2735 msg.contains("could not be confirmed stopped"),
2736 "must say what happened: {msg}"
2737 );
2738 assert!(
2739 msg.contains("rollback was skipped"),
2740 "must say the rollback was skipped: {msg}"
2741 );
2742 }
2743
2744 #[tokio::test]
2749 async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
2750 let mut child = tokio::process::Command::new("sh")
2751 .args(["-c", "sleep 3600 & wait"])
2752 .process_group(0)
2753 .stdin(Stdio::null())
2754 .stdout(Stdio::null())
2755 .stderr(Stdio::null())
2756 .spawn()
2757 .expect("spawn");
2758 SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
2759 let started = tokio::time::Instant::now();
2760 let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
2761 let elapsed = started.elapsed();
2762
2763 assert!(
2764 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
2765 "a killable group must report a plain stopped-timeout, got: {err:?}"
2766 );
2767 assert!(
2768 elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
2769 "must return promptly, took {elapsed:?}"
2770 );
2771 assert_eq!(
2775 SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
2776 0,
2777 "a killable group must not escalate to the privileged reaper"
2778 );
2779 }
2780
2781 #[tokio::test]
2789 async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
2790 let dir = tempfile::tempdir().expect("tempdir");
2791 let pgid_file = dir.path().join("pgid");
2792 let script = format!(
2796 "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
2797 pgid_file.display()
2798 );
2799 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
2800 let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
2801 .await
2802 .expect_err("non-UTF-8 stdout must fail the action");
2803 assert!(
2804 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
2805 "the read failure itself must be surfaced, got: {err:?}"
2806 );
2807
2808 let pgid: i32 = std::fs::read_to_string(&pgid_file)
2809 .expect("the shell must have recorded its pgid")
2810 .trim()
2811 .parse()
2812 .expect("pgid");
2813 assert!(
2814 probe_group(pgid).is_gone(),
2815 "process group {pgid} survived the read failure; \
2816 the privileged child was orphaned"
2817 );
2818 }
2819
2820 #[test]
2821 fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
2822 assert_eq!(
2824 reap_command(
2825 99,
2826 "sudo",
2827 &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
2828 ),
2829 vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
2830 );
2831 assert_eq!(
2833 reap_command(
2834 99,
2835 "sudo",
2836 &[
2837 "env".to_string(),
2838 "apt-get".to_string(),
2839 "install".to_string()
2840 ]
2841 ),
2842 root_group_kill_argv("TERM", 99)
2843 );
2844 }
2845
2846 #[test]
2847 fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
2848 assert_eq!(
2851 root_group_kill_argv("KILL", 4242),
2852 vec!["-n", "/usr/bin/kill", "-s", "KILL", "--", "-4242"]
2853 );
2854 assert_eq!(
2855 root_group_kill_argv("TERM", 7)[3..].to_vec(),
2856 vec!["TERM", "--", "-7"]
2857 );
2858 }
2859
2860 #[test]
2861 fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
2862 let sudo = |a: &[&str]| {
2863 is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
2864 };
2865 assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
2867 assert!(sudo(&[
2868 "rpm-ostree",
2869 "rebase",
2870 "fedora:fedora/40/x86_64/silverblue"
2871 ]));
2872 assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
2873 assert!(!sudo(&[
2876 "env",
2877 "DEBIAN_FRONTEND=noninteractive",
2878 "apt-get",
2879 "install",
2880 "vim"
2881 ]));
2882 assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
2883 assert!(!is_rpm_ostree_action("sudo", &[]));
2884 assert!(is_rpm_ostree_action(
2886 "/usr/bin/rpm-ostree",
2887 &["deploy".to_string()]
2888 ));
2889 assert!(!is_rpm_ostree_action(
2892 "sudo",
2893 &["explain-rpm-ostree-usage".to_string()]
2894 ));
2895 }
2896
2897 #[tokio::test]
2900 async fn real_executor_streams_each_stdout_line() {
2901 let spec = ActionSpec {
2902 action_name: "GetSystemState",
2903 mechanism: ActionMechanism::Command {
2904 program: "printf",
2905 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2906 },
2907 risk_level: RiskLevel::Low,
2908 reboot_required: false,
2909 rollback_available: false,
2910 };
2911 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2912 let out = RealActionExecutor
2913 .execute_with_progress(&spec, tx)
2914 .await
2915 .unwrap();
2916
2917 let mut lines = Vec::new();
2918 while let Ok(line) = rx.try_recv() {
2919 lines.push(line);
2920 }
2921 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2922 assert_eq!(out.exit_code, 0);
2923 assert!(out.stdout.contains("line-two"));
2924 }
2925
2926 #[tokio::test]
2927 async fn execute_spec_file_write_creates_file() {
2928 let dir = tempdir().unwrap();
2929 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2930 let spec = ActionSpec {
2931 action_name: "AddPackageRepository",
2932 mechanism: ActionMechanism::FileWrite {
2933 path: path.clone(),
2934 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2935 },
2936 risk_level: RiskLevel::Medium,
2937 reboot_required: false,
2938 rollback_available: true,
2939 };
2940 let out = execute_spec(&spec).await.unwrap();
2941 assert_eq!(out.exit_code, 0);
2942 assert_eq!(
2943 std::fs::read_to_string(&path).unwrap(),
2944 "[repo]\nbaseurl=https://example.test\n"
2945 );
2946 }
2947
2948 #[tokio::test]
2949 async fn execute_spec_file_patch_replaces_first_occurrence() {
2950 let dir = tempdir().unwrap();
2951 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2952 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2953 let spec = ActionSpec {
2954 action_name: "EnablePackageRepository",
2955 mechanism: ActionMechanism::FilePatch {
2956 path: path.clone(),
2957 search: "enabled=0".to_string(),
2958 replace: "enabled=1".to_string(),
2959 },
2960 risk_level: RiskLevel::Medium,
2961 reboot_required: false,
2962 rollback_available: true,
2963 };
2964 execute_spec(&spec).await.unwrap();
2965 assert_eq!(
2966 std::fs::read_to_string(&path).unwrap(),
2967 "[myrepo]\nenabled=1\n"
2968 );
2969 }
2970
2971 #[tokio::test]
2972 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2973 let dir = tempdir().unwrap();
2974 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2975 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2976 let spec = ActionSpec {
2977 action_name: "EnablePackageRepository",
2978 mechanism: ActionMechanism::FilePatch {
2979 path: path.clone(),
2980 search: "enabled=0".to_string(),
2981 replace: "enabled=1".to_string(),
2982 },
2983 risk_level: RiskLevel::Medium,
2984 reboot_required: false,
2985 rollback_available: true,
2986 };
2987 let out = execute_spec(&spec).await.unwrap();
2988 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2989 assert!(
2990 out.stderr.contains("search string not found in file"),
2991 "stderr should explain the failure: {}",
2992 out.stderr
2993 );
2994 assert_eq!(
2996 std::fs::read_to_string(&path).unwrap(),
2997 "[myrepo]\nenabled=1\n"
2998 );
2999 }
3000
3001 #[tokio::test]
3002 async fn execute_spec_file_patch_allows_empty_search_string() {
3003 let dir = tempdir().unwrap();
3006 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
3007 std::fs::write(&path, "hello").unwrap();
3008 let spec = ActionSpec {
3009 action_name: "Test",
3010 mechanism: ActionMechanism::FilePatch {
3011 path: path.clone(),
3012 search: String::new(),
3013 replace: "prefix-".to_string(),
3014 },
3015 risk_level: RiskLevel::Low,
3016 reboot_required: false,
3017 rollback_available: false,
3018 };
3019 let out = execute_spec(&spec).await.unwrap();
3020 assert_eq!(out.exit_code, 0);
3021 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3022 }
3023
3024 #[tokio::test]
3025 async fn execute_spec_file_delete_removes_file() {
3026 let dir = tempdir().unwrap();
3027 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3028 std::fs::write(&path, "[myrepo]\n").unwrap();
3029 let spec = ActionSpec {
3030 action_name: "RemovePackageRepository",
3031 mechanism: ActionMechanism::FileDelete { path: path.clone() },
3032 risk_level: RiskLevel::Medium,
3033 reboot_required: false,
3034 rollback_available: true,
3035 };
3036 execute_spec(&spec).await.unwrap();
3037 assert!(!std::path::Path::new(&path).exists());
3038 }
3039
3040 #[tokio::test]
3041 async fn execute_spec_file_scan_lists_directory_entries() {
3042 let dir = tempdir().unwrap();
3043 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3044 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3045 let spec = ActionSpec {
3046 action_name: "ListPackageRepositories",
3047 mechanism: ActionMechanism::FileScan {
3048 path: dir.path().to_string_lossy().into_owned(),
3049 },
3050 risk_level: RiskLevel::Low,
3051 reboot_required: false,
3052 rollback_available: false,
3053 };
3054 let out = execute_spec(&spec).await.unwrap();
3055 assert!(
3056 out.stdout.contains("a.repo"),
3057 "expected a.repo in: {}",
3058 out.stdout
3059 );
3060 assert!(
3061 out.stdout.contains("b.repo"),
3062 "expected b.repo in: {}",
3063 out.stdout
3064 );
3065 assert_eq!(out.exit_code, 0);
3066 }
3067
3068 #[test]
3071 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3072 let spec = rollback_spec_for("UpdateSystem").unwrap();
3073 assert_eq!(spec.action_name, "RollbackDeployment");
3074 assert!(
3075 matches!(
3076 &spec.mechanism,
3077 ActionMechanism::Command { program: "rpm-ostree", args }
3078 if args == &["rollback".to_string()]
3079 ),
3080 "expected rpm-ostree rollback, got: {:?}",
3081 spec.mechanism
3082 );
3083 assert!(!spec.rollback_available, "rollback spec must not recurse");
3084 }
3085
3086 #[test]
3087 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3088 assert!(rollback_spec_for("InstallPackages").is_some());
3089 }
3090
3091 #[test]
3092 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3093 assert!(rollback_spec_for("RemovePackages").is_some());
3094 }
3095
3096 #[test]
3097 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3098 assert!(rollback_spec_for("RebaseSystem").is_some());
3099 }
3100
3101 #[test]
3102 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3103 assert!(rollback_spec_for("SetKernelArguments").is_some());
3104 }
3105
3106 #[test]
3107 fn rollback_spec_for_read_only_action_returns_none() {
3108 assert!(rollback_spec_for("GetSystemState").is_none());
3109 assert!(rollback_spec_for("ListUsers").is_none());
3110 assert!(rollback_spec_for("GetFirewallState").is_none());
3111 }
3112
3113 #[test]
3114 fn rollback_spec_for_non_rollbackable_actions_return_none() {
3115 assert!(rollback_spec_for("AddUserToGroup").is_none());
3116 assert!(rollback_spec_for("DeleteUser").is_none());
3117 assert!(rollback_spec_for("CleanupDeployments").is_none());
3118 assert!(rollback_spec_for("RollbackDeployment").is_none());
3120 }
3121
3122 #[test]
3125 fn public_key_accepts_valid_ed25519() {
3126 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3127 assert!(validated_public_key(key).is_ok());
3128 }
3129
3130 #[test]
3131 fn public_key_accepts_valid_rsa() {
3132 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3133 assert!(validated_public_key(key).is_ok());
3134 }
3135
3136 #[test]
3137 fn public_key_rejects_empty() {
3138 assert!(matches!(
3139 validated_public_key(""),
3140 Err(ExecutorError::InvalidParam("public_key"))
3141 ));
3142 }
3143
3144 #[test]
3145 fn public_key_rejects_unknown_prefix() {
3146 assert!(matches!(
3147 validated_public_key("sk-rsa AAAA... user@host"),
3148 Err(ExecutorError::InvalidParam("public_key"))
3149 ));
3150 assert!(matches!(
3151 validated_public_key("AAAAB3Nz... user@host"),
3152 Err(ExecutorError::InvalidParam("public_key"))
3153 ));
3154 }
3155
3156 #[test]
3157 fn public_key_rejects_single_quote() {
3158 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3159 assert!(matches!(
3160 validated_public_key(key),
3161 Err(ExecutorError::InvalidParam("public_key"))
3162 ));
3163 }
3164
3165 #[test]
3173 fn package_actions_reject_a_local_file_target() {
3174 let reject: &[(&str, serde_json::Value)] = &[
3175 ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3176 ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3177 ("AptPurge", json!({"package": "./evil.deb"})),
3178 ("AptHold", json!({"package": "/tmp/evil.deb"})),
3179 ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3180 ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3181 ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3182 ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3183 (
3184 "ReplaceLayeredPackage",
3185 json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3186 ),
3187 (
3188 "ReplaceLayeredPackage",
3189 json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3190 ),
3191 ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3192 ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3193 ];
3194 for (action, params) in reject {
3195 assert!(
3196 matches!(
3197 build_action_spec(action, params),
3198 Err(ExecutorError::InvalidParam(_))
3199 ),
3200 "{action} must refuse a local-file package target: {params}"
3201 );
3202 }
3203 let accept: &[(&str, serde_json::Value)] = &[
3206 ("AptInstall", json!({"package": "nginx"})),
3207 ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3208 (
3209 "InstallPackages",
3210 json!({"packages": ["nginx", "python3-pip"]}),
3211 ),
3212 ];
3213 for (action, params) in accept {
3214 assert!(
3215 build_action_spec(action, params).is_ok(),
3216 "{action} must accept a real package spec: {params}"
3217 );
3218 }
3219 }
3220
3221 #[test]
3226 fn activating_actions_refuse_a_root_shell_unit() {
3227 for action in ["StartService", "RestartService", "UnmaskService"] {
3228 for unit in [
3229 "debug-shell.service",
3230 "emergency.target",
3231 "rescue.service",
3232 "runlevel1.target",
3233 ] {
3234 assert!(
3235 matches!(
3236 build_action_spec(action, &json!({"unit": unit})),
3237 Err(ExecutorError::InvalidParam("unit"))
3238 ),
3239 "{action} must not activate {unit}"
3240 );
3241 }
3242 assert!(
3243 build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3244 "{action} must still activate an ordinary unit"
3245 );
3246 }
3247 assert!(
3248 build_action_spec(
3249 "SetServiceEnabled",
3250 &json!({"unit": "debug-shell.service", "enabled": true})
3251 )
3252 .is_err(),
3253 "enabling a root-shell unit brings it up at boot and must be refused"
3254 );
3255 assert!(
3256 build_action_spec(
3257 "SetServiceEnabled",
3258 &json!({"unit": "debug-shell.service", "enabled": false})
3259 )
3260 .is_ok(),
3261 "disabling a root-shell unit is a mitigation and must stay reachable"
3262 );
3263 for action in ["StopService", "MaskService"] {
3266 assert!(
3267 build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3268 "{action} on debug-shell is a mitigation and must stay allowed"
3269 );
3270 }
3271 }
3272
3273 #[test]
3274 fn authorized_keys_actions_reject_a_traversal_username() {
3275 for action in [
3282 "GetAuthorizedKeys",
3283 "AddAuthorizedKey",
3284 "RemoveAuthorizedKey",
3285 ] {
3286 for bad in ["../../etc", "..", "root/../../etc"] {
3287 let err = build_action_spec(
3288 action,
3289 &json!({
3290 "username": bad,
3291 "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h",
3292 }),
3293 )
3294 .unwrap_err();
3295 assert!(
3296 matches!(err, ExecutorError::InvalidParam("username")),
3297 "{action} must reject username {bad:?}, got {err:?}"
3298 );
3299 }
3300 }
3301 }
3302
3303 #[test]
3304 fn ip_taking_actions_reject_a_malformed_address() {
3305 let cases: [(&str, serde_json::Value); 3] = [
3308 (
3309 "ResolvectlSetDns",
3310 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
3311 ),
3312 (
3313 "Fail2banBanIp",
3314 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
3315 ),
3316 (
3317 "Fail2banUnbanIp",
3318 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
3319 ),
3320 ];
3321 for (action, params) in cases {
3322 let err = build_action_spec(action, ¶ms).unwrap_err();
3323 assert!(
3324 matches!(err, ExecutorError::InvalidIpAddress { .. }),
3325 "{action} must reject a malformed IP, got {err:?}"
3326 );
3327 }
3328 }
3329
3330 #[test]
3331 fn public_key_rejects_pipe_metacharacter() {
3332 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
3336 assert!(matches!(
3337 validated_public_key(key),
3338 Err(ExecutorError::InvalidParam("public_key"))
3339 ));
3340 }
3341
3342 #[test]
3343 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
3344 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
3351 assert!(validated_public_key(key).is_ok());
3352 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
3355 }
3356
3357 #[test]
3358 fn public_key_rejects_shell_metacharacters() {
3359 for (metachar, desc) in [
3360 (';', "semicolon"),
3361 ('`', "backtick"),
3362 ('$', "dollar"),
3363 ('\\', "backslash"),
3364 ('&', "ampersand"),
3365 ] {
3366 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
3367 assert!(
3368 matches!(
3369 validated_public_key(&key),
3370 Err(ExecutorError::InvalidParam("public_key"))
3371 ),
3372 "{desc} should be rejected"
3373 );
3374 }
3375 }
3376
3377 #[test]
3378 fn public_key_rejects_newline() {
3379 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
3380 assert!(matches!(
3381 validated_public_key(key),
3382 Err(ExecutorError::InvalidParam("public_key"))
3383 ));
3384 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
3385 assert!(matches!(
3386 validated_public_key(key_cr),
3387 Err(ExecutorError::InvalidParam("public_key"))
3388 ));
3389 }
3390
3391 #[test]
3392 fn public_key_rejects_too_long() {
3393 let long_payload = "A".repeat(8192);
3395 let key = format!("ssh-ed25519 {long_payload} user@host");
3396 assert!(matches!(
3397 validated_public_key(&key),
3398 Err(ExecutorError::InvalidParam("public_key"))
3399 ));
3400 }
3401
3402 #[test]
3405 fn str_array_or_empty_rejects_non_string_element() {
3406 let params = json!({ "packages": ["vim", 42, "curl"] });
3407 assert!(matches!(
3408 str_array_or_empty(¶ms, "packages"),
3409 Err(ExecutorError::InvalidParam("packages"))
3410 ));
3411 }
3412
3413 #[test]
3414 fn str_array_or_empty_accepts_string_array() {
3415 let params = json!({ "packages": ["vim", "curl"] });
3416 assert_eq!(
3417 str_array_or_empty(¶ms, "packages").unwrap(),
3418 vec!["vim".to_string(), "curl".to_string()]
3419 );
3420 }
3421
3422 #[test]
3423 fn str_array_or_empty_returns_empty_when_key_absent() {
3424 let params = json!({});
3425 assert_eq!(
3426 str_array_or_empty(¶ms, "packages").unwrap(),
3427 Vec::<String>::new()
3428 );
3429 }
3430
3431 #[test]
3434 fn kernel_arg_allows_safe_args() {
3435 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3436 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3437 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3438 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3439 }
3440
3441 #[test]
3442 fn kernel_arg_blocks_init_override() {
3443 assert!(matches!(
3444 validated_safe_kernel_arg("init=/bin/sh", "add"),
3445 Err(ExecutorError::InvalidParam("add"))
3446 ));
3447 assert!(matches!(
3448 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3449 Err(ExecutorError::InvalidParam("add"))
3450 ));
3451 }
3452
3453 #[test]
3454 fn kernel_arg_blocks_selinux_disable() {
3455 assert!(matches!(
3456 validated_safe_kernel_arg("selinux=0", "add"),
3457 Err(ExecutorError::InvalidParam("add"))
3458 ));
3459 assert!(matches!(
3460 validated_safe_kernel_arg("enforcing=0", "add"),
3461 Err(ExecutorError::InvalidParam("add"))
3462 ));
3463 }
3464
3465 #[test]
3466 fn kernel_arg_blocks_security_override() {
3467 assert!(matches!(
3468 validated_safe_kernel_arg("security=none", "add"),
3469 Err(ExecutorError::InvalidParam("add"))
3470 ));
3471 }
3472
3473 #[test]
3474 fn kernel_arg_blocks_module_blacklist() {
3475 assert!(matches!(
3476 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3477 Err(ExecutorError::InvalidParam("add"))
3478 ));
3479 }
3480
3481 #[test]
3489 fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
3490 for arg in [
3493 "lockdown=confidentiality",
3494 "module.sig_enforce=1",
3495 "selinux=1",
3496 "enforcing=1",
3497 "apparmor=1",
3498 "security=selinux",
3499 "pti=on",
3500 "mitigations=auto,nosmt",
3501 "init_on_alloc=1",
3502 "init_on_free=1",
3503 "slab_nomerge",
3504 "page_alloc.shuffle=1",
3505 "randomize_kstack_offset=on",
3506 "slub_debug=FZP",
3507 "vsyscall=none",
3508 "debugfs=off",
3509 "iommu=force",
3510 "intel_iommu=on",
3511 "amd_iommu=force_isolation",
3512 ] {
3513 assert!(
3514 matches!(
3515 validated_safe_kernel_arg_removal(arg, "remove"),
3516 Err(ExecutorError::InvalidParam("remove"))
3517 ),
3518 "removing {arg} is a boot-security downgrade and must be refused"
3519 );
3520 }
3521 }
3522
3523 #[test]
3527 fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
3528 for arg in [
3529 "mitigations=off",
3530 "selinux=0",
3531 "enforcing=0",
3532 "apparmor=0",
3533 "pti=off",
3534 "lockdown=none",
3535 "debugfs=on",
3536 "init=/bin/sh",
3537 "single",
3538 ] {
3539 assert!(
3540 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3541 "{arg} weakens the host, so removing it must stay allowed"
3542 );
3543 }
3544 }
3545
3546 #[test]
3547 fn kernel_arg_removal_allows_ordinary_args() {
3548 for arg in [
3549 "quiet",
3550 "splash",
3551 "nomodeset",
3552 "console=ttyS0,115200",
3553 "rd.driver.blacklist=nouveau",
3554 ] {
3555 assert!(
3556 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3557 "{arg} carries no boot-security meaning and must stay removable"
3558 );
3559 }
3560 }
3561
3562 #[test]
3563 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
3564 assert!(matches!(
3565 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
3566 Err(ExecutorError::InvalidParam("add"))
3567 ));
3568 assert!(matches!(
3569 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
3570 Err(ExecutorError::InvalidParam("add"))
3571 ));
3572 assert!(matches!(
3573 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
3574 Err(ExecutorError::InvalidParam("add"))
3575 ));
3576 }
3577
3578 #[test]
3579 fn kernel_arg_blocks_apparmor_disable() {
3580 assert!(matches!(
3581 validated_safe_kernel_arg("apparmor=0", "add"),
3582 Err(ExecutorError::InvalidParam("add"))
3583 ));
3584 }
3585
3586 #[test]
3587 fn kernel_arg_blocks_mitigations_off() {
3588 assert!(matches!(
3589 validated_safe_kernel_arg("mitigations=off", "add"),
3590 Err(ExecutorError::InvalidParam("add"))
3591 ));
3592 }
3593
3594 #[test]
3595 fn kernel_arg_blocks_lockdown_override() {
3596 assert!(matches!(
3597 validated_safe_kernel_arg("lockdown=none", "add"),
3598 Err(ExecutorError::InvalidParam("add"))
3599 ));
3600 }
3601
3602 #[test]
3603 fn kernel_arg_blocks_pti_off() {
3604 assert!(matches!(
3605 validated_safe_kernel_arg("pti=off", "add"),
3606 Err(ExecutorError::InvalidParam("add"))
3607 ));
3608 }
3609
3610 #[test]
3611 fn kernel_arg_blocks_smap_smep_disable() {
3612 assert!(matches!(
3613 validated_safe_kernel_arg("nosmap", "add"),
3614 Err(ExecutorError::InvalidParam("add"))
3615 ));
3616 assert!(matches!(
3617 validated_safe_kernel_arg("nosmep", "add"),
3618 Err(ExecutorError::InvalidParam("add"))
3619 ));
3620 }
3621
3622 #[test]
3623 fn kernel_arg_blocks_single_user_shortcuts() {
3624 assert!(matches!(
3626 validated_safe_kernel_arg("single", "add"),
3627 Err(ExecutorError::InvalidParam("add"))
3628 ));
3629 assert!(matches!(
3630 validated_safe_kernel_arg("s", "add"),
3631 Err(ExecutorError::InvalidParam("add"))
3632 ));
3633 assert!(matches!(
3634 validated_safe_kernel_arg("1", "add"),
3635 Err(ExecutorError::InvalidParam("add"))
3636 ));
3637 }
3638
3639 #[test]
3640 fn signal_process_guardrails() {
3641 for bad_pid in [0, 1] {
3643 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
3644 assert!(
3645 matches!(err, ExecutorError::InvalidParam("pid")),
3646 "pid {bad_pid} must be rejected, got {err:?}"
3647 );
3648 }
3649 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
3651 .unwrap_err();
3652 assert!(
3653 matches!(err, ExecutorError::InvalidParam("signal")),
3654 "got {err:?}"
3655 );
3656 assert!(matches!(
3658 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
3659 ExecutorError::MissingParam("pid")
3660 ));
3661 let spec = build_action_spec(
3663 "SignalProcess",
3664 &json!({ "pid": "4242", "signal": "sigkill" }),
3665 )
3666 .unwrap();
3667 assert_eq!(spec.action_name, "SignalProcess");
3668 assert_eq!(
3669 spec.mechanism,
3670 ActionMechanism::Command {
3671 program: "sudo",
3672 args: vec![
3673 "kill".to_string(),
3674 "-s".to_string(),
3675 "KILL".to_string(),
3676 "4242".to_string()
3677 ],
3678 }
3679 );
3680 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
3682 if let ActionMechanism::Command { args, .. } = spec.mechanism {
3683 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
3684 } else {
3685 panic!("expected Command mechanism");
3686 }
3687 }
3688
3689 #[test]
3690 fn create_scheduled_job_validates_name_command_schedule() {
3691 assert!(matches!(
3693 build_action_spec(
3694 "CreateScheduledJob",
3695 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
3696 )
3697 .unwrap_err(),
3698 ExecutorError::InvalidParam("command")
3699 ));
3700 assert!(matches!(
3702 build_action_spec(
3703 "CreateScheduledJob",
3704 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
3705 )
3706 .unwrap_err(),
3707 ExecutorError::InvalidParam("name")
3708 ));
3709 assert!(matches!(
3711 build_action_spec(
3712 "CreateScheduledJob",
3713 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
3714 )
3715 .unwrap_err(),
3716 ExecutorError::InvalidParam("schedule")
3717 ));
3718 let spec = build_action_spec(
3720 "CreateScheduledJob",
3721 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
3722 )
3723 .unwrap();
3724 assert_eq!(
3725 spec.mechanism,
3726 ActionMechanism::Command {
3727 program: "sudo",
3728 args: vec![
3729 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
3730 "--name".to_string(),
3731 "nightly-backup".to_string(),
3732 "--command".to_string(),
3733 "/usr/bin/backup --full".to_string(),
3734 "--schedule".to_string(),
3735 "*-*-* 02:00:00".to_string(),
3736 ],
3737 }
3738 );
3739 }
3740
3741 #[test]
3742 fn set_sshd_option_allowlist_guardrails() {
3743 assert!(matches!(
3745 build_action_spec(
3746 "SetSshdOption",
3747 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
3748 )
3749 .unwrap_err(),
3750 ExecutorError::InvalidParam("option")
3751 ));
3752 assert!(matches!(
3754 build_action_spec(
3755 "SetSshdOption",
3756 &json!({ "option": "PermitRootLogin", "value": "maybe" })
3757 )
3758 .unwrap_err(),
3759 ExecutorError::InvalidParam("value")
3760 ));
3761 let spec = build_action_spec(
3763 "SetSshdOption",
3764 &json!({ "option": "PasswordAuthentication", "value": "no" }),
3765 )
3766 .unwrap();
3767 assert_eq!(
3768 spec.mechanism,
3769 ActionMechanism::Command {
3770 program: "sudo",
3771 args: vec![
3772 "/usr/lib/sysknife/sshd-option-edit".to_string(),
3773 "--option".to_string(),
3774 "PasswordAuthentication".to_string(),
3775 "--value".to_string(),
3776 "no".to_string(),
3777 ],
3778 }
3779 );
3780 }
3781
3782 #[test]
3783 fn configure_unattended_upgrades_toggles_helper_flag() {
3784 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
3785 let spec = build_action_spec(
3786 "ConfigureUnattendedUpgrades",
3787 &json!({ "enabled": enabled }),
3788 )
3789 .unwrap();
3790 assert_eq!(
3791 spec.mechanism,
3792 ActionMechanism::Command {
3793 program: "sudo",
3794 args: vec![
3795 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
3796 flag.to_string(),
3797 ],
3798 }
3799 );
3800 }
3801 assert!(matches!(
3802 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
3803 ExecutorError::MissingParam("enabled")
3804 ));
3805 }
3806
3807 #[test]
3808 fn grub_set_kargs_append_blocks_single_user_shortcut() {
3809 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
3817 let err = build_action_spec(
3818 "GrubSetKargs",
3819 &json!({ "append": [dangerous], "delete": [] }),
3820 )
3821 .unwrap_err();
3822 assert!(
3823 matches!(err, ExecutorError::InvalidParam("append")),
3824 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
3825 );
3826 }
3827 assert!(build_action_spec(
3829 "GrubSetKargs",
3830 &json!({ "append": ["quiet"], "delete": [] })
3831 )
3832 .is_ok());
3833 }
3834
3835 #[test]
3836 fn kernel_arg_build_spec_rejects_dangerous_arg() {
3837 let err = build_action_spec(
3843 "SetKernelArguments",
3844 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
3845 )
3846 .unwrap_err();
3847 assert!(
3848 matches!(err, ExecutorError::InvalidParam("add")),
3849 "expected InvalidParam(add), got {err:?}"
3850 );
3851 }
3852
3853 #[test]
3854 fn grub_set_kargs_cannot_delete_a_protective_karg() {
3855 for karg in [
3862 "apparmor=1",
3863 "lockdown=confidentiality",
3864 "mitigations=auto",
3865 "pti=on",
3866 "selinux=1",
3867 ] {
3868 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
3869 .unwrap_err();
3870 assert!(
3871 matches!(err, ExecutorError::InvalidParam("delete")),
3872 "deleting {karg:?} must be refused, got {err:?}"
3873 );
3874 }
3875 }
3876
3877 #[test]
3882 fn rollback_available_matches_rollback_spec_for_all_actions() {
3883 for spec in crate::actions::all_specs() {
3891 let has_rollback = rollback_spec_for(spec.action_name).is_some();
3892 assert_eq!(
3893 spec.rollback_available,
3894 has_rollback,
3895 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
3896 spec.action_name,
3897 spec.rollback_available,
3898 if has_rollback { "Some" } else { "None" },
3899 );
3900 }
3901 }
3902
3903 #[test]
3911 fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
3912 for home in [
3913 "../../etc/skel",
3914 "/home/../etc",
3915 "home/alice",
3916 "/home/alice/../../../root",
3917 ] {
3918 let got =
3919 build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
3920 assert!(
3921 got.is_err(),
3922 "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
3923 );
3924 }
3925 build_action_spec(
3928 "CreateUser",
3929 &json!({ "username": "alice", "home": "/home/alice" }),
3930 )
3931 .expect("a plain absolute home must still be accepted");
3932 }
3933
3934 #[test]
3940 fn create_user_is_high_risk() {
3941 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
3942 assert_eq!(
3943 spec.risk_level,
3944 RiskLevel::High,
3945 "CreateUser must be High (T1136.001 Persistence)"
3946 );
3947 }
3948
3949 #[test]
3950 fn configure_firewall_is_high_risk() {
3951 let spec = build_action_spec(
3952 "ConfigureFirewall",
3953 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
3954 )
3955 .unwrap();
3956 assert_eq!(
3957 spec.risk_level,
3958 RiskLevel::High,
3959 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
3960 );
3961 }
3962
3963 #[test]
3964 fn mask_service_is_high_risk() {
3965 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
3966 assert_eq!(
3967 spec.risk_level,
3968 RiskLevel::High,
3969 "MaskService must be High (T1562.001 Impair Defenses)"
3970 );
3971 }
3972
3973 #[test]
3974 fn add_package_repository_is_high_risk() {
3975 let spec = build_action_spec(
3976 "AddPackageRepository",
3977 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
3978 )
3979 .unwrap();
3980 assert_eq!(
3981 spec.risk_level,
3982 RiskLevel::High,
3983 "AddPackageRepository must be High (supply-chain vector)"
3984 );
3985 }
3986
3987 #[test]
3988 fn set_dns_servers_is_high_risk() {
3989 let spec = build_action_spec(
3990 "SetDnsServers",
3991 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
3992 )
3993 .unwrap();
3994 assert_eq!(
3995 spec.risk_level,
3996 RiskLevel::High,
3997 "SetDnsServers must be High (DNS hijacking / T1557)"
3998 );
3999 }
4000}