1use crate::actions::{
2 apparmor, apt, apt_preferences, auditd, certbot, cloudinit, containers, deployment, distrobox,
3 fail2ban, filesystem, flatpak, grub, identity, journald, layering, livepatch, logging, lvm,
4 mounts, multipass, netplan, network, package_repos, pam, ppa, processes, reboot,
5 release_upgrade, resolvectl, services, snap, ssh, sudoers, sysctl, system_info, toolbox,
6 ubuntu_pro, ufw, users,
7 validate::{
8 shell_equivalent_sudo_command, validated_activatable_unit, validated_apparmor_profile,
9 validated_apt_package, validated_apt_pin_expr, validated_apt_pin_name,
10 validated_audit_path, validated_audit_perms, validated_cpu_quota, validated_domain,
11 validated_email, validated_fstype, validated_group, validated_group_not_critical,
12 validated_home_dir, validated_hostname, validated_install_package, validated_journal_grep,
13 validated_journal_priority, validated_journal_time, validated_locale, validated_log_path,
14 validated_lvm_name, validated_lvm_size, validated_memory_limit, validated_mount_device,
15 validated_mount_options, validated_mount_point, validated_port_or_service,
16 validated_ppa_name, validated_pro_service, validated_resource_limit_unit,
17 validated_safe_arg, validated_sudo_commands, validated_sudoers_name, validated_swap_path,
18 validated_sysctl_key, validated_sysctl_value, validated_syslog_host, validated_tasks_max,
19 validated_timezone, validated_unit_name, validated_username,
20 validated_username_not_critical,
21 },
22 ActionMechanism, ActionSpec,
23};
24use async_trait::async_trait;
25use serde_json::Value;
26use std::io;
27use std::net::IpAddr;
28use std::process::Stdio;
29use std::str::FromStr;
30use std::time::Duration;
31use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
32use tokio::sync::mpsc::UnboundedSender;
33
34const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
45const MAX_SCHEDULE_EXPR_LEN: usize = 128;
47const MAX_JOURNAL_LINES: u64 = 10_000;
50const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
52const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
54const MAX_PASSWORD_MINLEN: u64 = 128;
56const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
58const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
60
61#[derive(Debug, thiserror::Error)]
62pub enum ExecutorError {
63 #[error("unknown action: {0}")]
64 UnknownAction(String),
65
66 #[error("missing required param: {0}")]
67 MissingParam(&'static str),
68
69 #[error("invalid param type for: {0}")]
70 InvalidParam(&'static str),
71
72 #[error("invalid IP address for param '{param}': '{value}'")]
78 InvalidIpAddress { param: &'static str, value: String },
79
80 #[error("io error: {0}")]
81 Io(#[from] io::Error),
82
83 #[error(
92 "{program} exceeded the {timeout_secs}s action timeout and could not be \
93 confirmed stopped: process group {pgid} still has members. Automatic \
94 rollback was skipped because the original action may still be running; \
95 inspect the host before retrying"
96 )]
97 ActionNotStopped {
98 program: String,
99 pgid: i32,
100 timeout_secs: u64,
101 },
102}
103
104#[derive(Debug, Clone, PartialEq, Eq)]
113pub struct ExecutionOutput {
114 pub stdout: String,
115 pub stderr: String,
116 pub exit_code: i32,
117}
118
119impl ExecutionOutput {
120 pub fn is_success(&self) -> bool {
122 self.exit_code == 0
123 }
124
125 pub fn is_nonzero(&self) -> bool {
127 self.exit_code != 0
128 }
129}
130
131#[async_trait]
138pub trait ActionExecutor: Send + Sync {
139 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
141
142 async fn execute_with_progress(
148 &self,
149 spec: &ActionSpec,
150 progress: UnboundedSender<String>,
151 ) -> Result<ExecutionOutput, ExecutorError> {
152 let output = self.execute(spec).await?;
153 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
154 let _ = progress.send(line.to_string());
155 }
156 Ok(output)
157 }
158}
159
160pub struct RealActionExecutor;
162
163#[async_trait]
164impl ActionExecutor for RealActionExecutor {
165 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
166 execute_spec(spec).await
167 }
168
169 async fn execute_with_progress(
170 &self,
171 spec: &ActionSpec,
172 progress: UnboundedSender<String>,
173 ) -> Result<ExecutionOutput, ExecutorError> {
174 match &spec.mechanism {
175 ActionMechanism::Command { program, args } => {
176 execute_command_with_progress(program, args, progress).await
177 }
178 _ => execute_spec(spec).await,
179 }
180 }
181}
182
183const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
197
198fn action_timeout() -> Duration {
199 match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
200 Ok(raw) => match raw.trim().parse::<u64>() {
201 Ok(secs) if secs > 0 => Duration::from_secs(secs),
202 _ => {
203 eprintln!(
204 "[sysknife-daemon] WARNING: ignoring invalid \
205 SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
206 );
207 DEFAULT_ACTION_TIMEOUT
208 }
209 },
210 Err(_) => DEFAULT_ACTION_TIMEOUT,
211 }
212}
213
214#[derive(Debug, Clone, Copy, PartialEq, Eq)]
216enum GroupState {
217 Gone,
219 AliveReachable,
221 AliveUnreachable,
226 Unknown(i32),
229}
230
231impl GroupState {
232 fn is_gone(self) -> bool {
233 matches!(self, GroupState::Gone)
234 }
235}
236
237fn probe_group(pgid: i32) -> GroupState {
239 let rc = unsafe { libc::killpg(pgid, 0) };
244 if rc == 0 {
245 return GroupState::AliveReachable;
246 }
247 match io::Error::last_os_error().raw_os_error() {
248 Some(libc::ESRCH) => GroupState::Gone,
249 Some(libc::EPERM) => GroupState::AliveUnreachable,
250 other => GroupState::Unknown(other.unwrap_or(0)),
251 }
252}
253
254fn signal_group(pgid: i32, signal: i32) -> Result<(), io::Error> {
256 let rc = unsafe { libc::killpg(pgid, signal) };
258 if rc == 0 {
259 return Ok(());
260 }
261 let err = io::Error::last_os_error();
262 if err.raw_os_error() == Some(libc::ESRCH) {
263 return Ok(());
264 }
265 Err(err)
266}
267
268const GROUP_TERM_GRACE: Duration = Duration::from_secs(5);
273
274fn is_rpm_ostree_action(program: &str, args: &[String]) -> bool {
280 let is_ostree = |s: &str| s == "rpm-ostree" || s.ends_with("/rpm-ostree");
281 is_ostree(program) || args.iter().any(|a| is_ostree(a))
282}
283
284fn root_group_kill_argv(signal: &str, pgid: i32) -> Vec<String> {
290 vec![
291 "-n".to_string(),
292 "/usr/bin/kill".to_string(),
293 "-s".to_string(),
294 signal.to_string(),
295 "--".to_string(),
296 format!("-{pgid}"),
297 ]
298}
299
300#[cfg(test)]
303static SUDO_INVOCATIONS: std::sync::atomic::AtomicUsize = std::sync::atomic::AtomicUsize::new(0);
304
305async fn run_sudo(args: Vec<String>) {
312 #[cfg(test)]
313 SUDO_INVOCATIONS.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
314 let fut = tokio::process::Command::new("sudo")
315 .args(&args)
316 .stdin(Stdio::null())
317 .stdout(Stdio::null())
318 .stderr(Stdio::null())
319 .status();
320 let joined = args.join(" ");
321 match tokio::time::timeout(REAP_WAIT_CAP, fut).await {
322 Ok(Ok(status)) if status.success() => {}
323 Ok(Ok(status)) => {
324 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` exited {status}")
325 }
326 Ok(Err(e)) => {
327 eprintln!("[sysknife-daemon] privileged reap `sudo {joined}` could not run: {e}")
328 }
329 Err(_) => eprintln!(
330 "[sysknife-daemon] privileged reap `sudo {joined}` timed out after {}s",
331 REAP_WAIT_CAP.as_secs()
332 ),
333 }
334}
335
336fn reap_command(pgid: i32, program: &str, args: &[String]) -> Vec<String> {
342 if is_rpm_ostree_action(program, args) {
343 vec![
344 "-n".to_string(),
345 "/usr/bin/rpm-ostree".to_string(),
346 "cancel".to_string(),
347 ]
348 } else {
349 root_group_kill_argv("TERM", pgid)
350 }
351}
352
353async fn privileged_reap(pgid: i32, program: &str, args: &[String]) {
359 run_sudo(reap_command(pgid, program, args)).await;
360 if is_rpm_ostree_action(program, args) {
367 return;
368 }
369 tokio::time::sleep(Duration::from_millis(300)).await;
370 if !probe_group(pgid).is_gone() {
371 run_sudo(root_group_kill_argv("KILL", pgid)).await;
372 }
373}
374
375const REAP_WAIT_CAP: Duration = Duration::from_secs(2);
383
384async fn kill_and_reap(
402 child: &mut tokio::process::Child,
403 program: &str,
404 args: &[String],
405 cause: StopCause,
406) -> ExecutorError {
407 let secs = action_timeout().as_secs();
408 let why = cause.describe(secs);
409 let Some(pgid) = child.id().map(|pid| pid as i32) else {
410 eprintln!("[sysknife-daemon] {program} {why}; already exited");
413 return cause.into_error(program, secs);
414 };
415
416 let _ = child.start_kill();
420 if let Err(err) = signal_group(pgid, libc::SIGTERM) {
421 eprintln!("[sysknife-daemon] SIGTERM to process group {pgid} failed: {err}");
422 }
423
424 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
428
429 let grace = tokio::time::Instant::now() + GROUP_TERM_GRACE;
430 while tokio::time::Instant::now() < grace && !probe_group(pgid).is_gone() {
431 tokio::time::sleep(Duration::from_millis(50)).await;
432 }
433 if !probe_group(pgid).is_gone() {
434 if let Err(err) = signal_group(pgid, libc::SIGKILL) {
435 eprintln!("[sysknife-daemon] SIGKILL to process group {pgid} failed: {err}");
436 }
437 tokio::time::sleep(Duration::from_millis(200)).await;
438 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
439 }
440
441 if probe_group(pgid) == GroupState::AliveUnreachable {
457 eprintln!(
458 "[sysknife-daemon] {program} process group {pgid} survived as root; \
459 escalating to a privileged reap"
460 );
461 privileged_reap(pgid, program, args).await;
462 tokio::time::sleep(Duration::from_millis(200)).await;
463 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
464 }
465
466 match probe_group(pgid) {
467 GroupState::Gone => {
468 eprintln!("[sysknife-daemon] {program} {why}; process group {pgid} stopped");
469 cause.into_error(program, secs)
470 }
471 state => {
472 eprintln!(
473 "[sysknife-daemon] {program} {why} and process group {pgid} \
474 could not be confirmed stopped ({state:?})"
475 );
476 ExecutorError::ActionNotStopped {
477 program: program.to_string(),
478 pgid,
479 timeout_secs: secs,
480 }
481 }
482 }
483}
484
485enum StopCause {
493 Timeout,
495 Unreadable(io::Error),
499}
500
501impl StopCause {
502 fn describe(&self, secs: u64) -> String {
504 match self {
505 StopCause::Timeout => format!("exceeded the {secs}s action timeout"),
506 StopCause::Unreadable(err) => format!("could not be read ({err})"),
507 }
508 }
509
510 fn into_error(self, program: &str, secs: u64) -> ExecutorError {
515 match self {
516 StopCause::Timeout => ExecutorError::Io(io::Error::new(
517 io::ErrorKind::TimedOut,
518 format!("{program} exceeded the {secs}s action timeout and was stopped"),
519 )),
520 StopCause::Unreadable(err) => ExecutorError::Io(err),
521 }
522 }
523}
524
525async fn execute_command_with_progress(
526 program: &'static str,
527 args: &[String],
528 progress: UnboundedSender<String>,
529) -> Result<ExecutionOutput, ExecutorError> {
530 let mut child = tokio::process::Command::new(program)
531 .args(args)
532 .process_group(0)
537 .stdin(Stdio::null())
542 .stdout(Stdio::piped())
543 .stderr(Stdio::piped())
544 .spawn()
545 .map_err(ExecutorError::Io)?;
546
547 let stdout = child.stdout.take().expect("stdout was piped");
548 let stderr = child.stderr.take().expect("stderr was piped");
549 let stderr_task = tokio::spawn(async move {
550 let mut buf = Vec::new();
551 BufReader::new(stderr)
552 .read_to_end(&mut buf)
553 .await
554 .map(|_| buf)
555 });
556
557 let deadline = tokio::time::Instant::now() + action_timeout();
558
559 let mut lines = BufReader::new(stdout).lines();
560 let mut stdout_buf = String::new();
561 loop {
562 let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
565 Ok(Ok(line)) => line,
566 Ok(Err(err)) => {
571 return Err(
572 kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await,
573 )
574 }
575 Err(_) => {
576 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
577 }
578 };
579 let Some(line) = next else { break };
580 if !line.is_empty() {
581 let _ = progress.send(line.clone());
582 }
583 stdout_buf.push_str(&line);
584 stdout_buf.push('\n');
585 }
586
587 let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
588 Ok(Ok(status)) => status,
589 Ok(Err(err)) => {
592 return Err(kill_and_reap(&mut child, program, args, StopCause::Unreadable(err)).await)
593 }
594 Err(_) => return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await),
595 };
596 let stderr_bytes = stderr_task
597 .await
598 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
599 .map_err(ExecutorError::Io)?;
600
601 Ok(ExecutionOutput {
602 stdout: stdout_buf,
603 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
604 exit_code: exit_status.code().unwrap_or(-1),
605 })
606}
607
608fn allowed_action_params(action_name: &str) -> Option<&'static [&'static str]> {
612 Some(match action_name {
613 "GetSystemState"
614 | "CollectDiagnostics"
615 | "GetDeploymentHistory"
616 | "ListDeployments"
617 | "UpdateSystem"
618 | "CleanupDeployments"
619 | "RebootSystem"
620 | "RollbackDeployment"
621 | "GetKernelArguments"
622 | "GetLayeredPackages"
623 | "ResetLayeredPackageOverride"
624 | "GetPendingUpdates"
625 | "ListPackageRepositories"
626 | "ListServices"
627 | "ListTimers"
628 | "ReloadDaemon"
629 | "GetDateTime"
630 | "GetDiskUsage"
631 | "ListProcesses"
632 | "GetLvmReport"
633 | "GetMounts"
634 | "GetSudoGrants"
635 | "RemoveRemoteSyslog"
636 | "GetMemoryInfo"
637 | "GetHostState"
638 | "GetFirewallState"
639 | "GetNetworkStatus"
640 | "GetListeningPorts"
641 | "GetNftablesRuleset"
642 | "GetFirewallBackendState"
643 | "ListUsers"
644 | "ListGroups"
645 | "AptUpdate"
646 | "AptUpgrade"
647 | "AptAutoremove"
648 | "AptListInstalled"
649 | "AptListUpgradable"
650 | "AptHistoryList"
651 | "SnapList"
652 | "GrubGetKargs"
653 | "CheckPendingReboot"
654 | "UfwEnable"
655 | "UfwDisable"
656 | "UfwReset"
657 | "DistroboxList"
658 | "NetplanGetConfig"
659 | "NetplanApply"
660 | "NetplanGenerate"
661 | "ProStatus"
662 | "ProDetach"
663 | "LivepatchStatus"
664 | "MultipassList"
665 | "UbuntuReleaseUpgrade"
666 | "ResolvectlStatus"
667 | "AppArmorStatus"
668 | "CloudInitStatus"
669 | "GetAuditRules"
670 | "GetCertificates"
671 | "RenewCertificates" => &[],
672 "PinDeployment" | "UnpinDeployment" => &["index"],
673 "RebaseSystem" => &["target_ref"],
674 "SetKernelArguments" => &["add", "remove"],
675 "ListFlatpakRemotes"
676 | "ListInstalledFlatpaks"
677 | "ListContainers"
678 | "ListToolboxes"
679 | "DeleteUser"
680 | "LockUserAccount"
681 | "UnlockUserAccount"
682 | "GetAuthorizedKeys"
683 | "UbuntuListFlatpaks" => &["user", "username"],
684 "InstallFlatpak" | "UbuntuInstallFlatpak" => &["app_id", "remote", "user", "username"],
685 "RemoveFlatpak"
686 | "GetFlatpakAppInfo"
687 | "UpdateFlatpak"
688 | "UbuntuRemoveFlatpak"
689 | "UbuntuUpdateFlatpak" => &["app_id", "user", "username"],
690 "SearchFlatpakApps" | "AptSearch" => &["term"],
691 "AddFlatpakRemote" => &["remote", "url", "user", "username"],
692 "RemoveFlatpakRemote" => &["remote", "user", "username"],
693 "CreateContainer" => &["image", "name", "user", "username"],
694 "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
695 | "RemoveToolbox" => &["name", "user", "username"],
696 "InstallPackages" | "RemovePackages" => &["packages"],
697 "AddLayeredPackage"
698 | "RemoveLayeredPackage"
699 | "RemoveBasePackage"
700 | "GetAptPins"
701 | "AptInstall"
702 | "AptRemove"
703 | "AptPurge"
704 | "AptHold"
705 | "AptUnhold"
706 | "AptShow" => &["package"],
707 "ReplaceLayeredPackage" => &["new", "old"],
708 "AddPackageRepository" => &["repo_id", "repo_url"],
709 "RemovePackageRepository" | "EnablePackageRepository" | "DisablePackageRepository" => {
710 &["repo_id"]
711 }
712 "StartService"
713 | "StopService"
714 | "RestartService"
715 | "MaskService"
716 | "UnmaskService"
717 | "GetServiceLogs"
718 | "GetServiceStatus"
719 | "ReloadService"
720 | "GetServiceResourceLimits" => &["unit"],
721 "SetServiceEnabled" => &["enabled", "unit"],
722 "CreateScheduledJob" => &["command", "name", "schedule"],
723 "SetServiceResourceLimits" => &[
724 "cpu_quota",
725 "memory_high",
726 "memory_max",
727 "tasks_max",
728 "unit",
729 ],
730 "CreateToolbox" => &["image", "name", "release", "user", "username"],
731 "SetHostname" => &["hostname"],
732 "SetTimezone" => &["timezone"],
733 "SetLocale" => &["locale"],
734 "SetNtp" | "ConfigureUnattendedUpgrades" => &["enabled"],
735 "SignalProcess" => &["pid", "signal"],
736 "GetJournalLog" => &[
737 "boot", "grep", "kernel", "lines", "priority", "since", "unit", "until",
738 ],
739 "VacuumJournal" => &["retain_days", "size_mb"],
740 "ExtendLogicalVolume" => &["lv", "size", "vg"],
741 "CreateLogicalVolume" => &["name", "size", "vg"],
742 "CreateLvSnapshot" => &["origin", "size", "snapshot", "vg"],
743 "GetSysctl" | "RemoveAuditRule" => &["key"],
744 "SetSysctl" | "NetplanSet" => &["key", "value"],
745 "AddMount" => &["device", "fstype", "mountpoint", "options"],
746 "RemoveMount" => &["mountpoint"],
747 "AddSwap" => &["file", "size_mb"],
748 "RemoveSwap" => &["file"],
749 "GrantSudoAccess" => &["commands", "name", "nopasswd", "runas", "user"],
750 "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" | "AddPpa" | "RemovePpa"
751 | "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo" | "SnapRevert"
752 | "SnapClassicInstall" | "DistroboxRemove" => &["name"],
753 "SetAptPin" => &["name", "package", "pin", "priority"],
754 "GetLogrotateStatus" => &["config"],
755 "ConfigureLogRotation" => &["compress", "frequency", "name", "path", "rotate"],
756 "ConfigureRemoteSyslog" => &["host", "port", "protocol"],
757 "GetPasswordAging" => &["user"],
758 "SetPasswordAging" => &["max_days", "min_days", "user", "warn_days"],
759 "SetPasswordPolicy" => &["dcredit", "lcredit", "minlen", "ocredit", "ucredit"],
760 "SetAccountLockout" => &["deny", "fail_interval", "unlock_time"],
761 "ConfigureWifi" => &["password", "ssid"],
762 "SetDnsServers" | "ResolvectlSetDns" => &["interface", "servers"],
763 "ConfigureFirewall" => &["enabled", "service", "zone"],
764 "CreateUser" => &["home", "shell", "user", "username"],
765 "AddUserToGroup" | "RemoveUserFromGroup" => &["group", "user", "username"],
766 "CreateGroup" => &["group", "system"],
767 "DeleteGroup" => &["group"],
768 "AddAuthorizedKey" | "RemoveAuthorizedKey" => &["public_key", "user", "username"],
769 "SetSshdOption" => &["option", "value"],
770 "SnapInstall" => &["auto_update", "channel", "name"],
771 "GrubSetKargs" => &["append", "delete"],
772 "UfwAllow" | "UfwDeny" => &["port_or_service"],
773 "UfwStatus" => &["numbered"],
774 "DistroboxCreate" => &["image", "name"],
775 "UfwDeleteRule" => &["rule_number"],
776 "UfwLimit" => &["target"],
777 "ProAttach" => &["token"],
778 "EnableProService" | "DisableProService" => &["service"],
779 "AppArmorEnforce" | "AppArmorComplain" => &["profile_path"],
780 "Fail2banStatus" => &["jail"],
781 "Fail2banBanIp" | "Fail2banUnbanIp" => &["ip", "jail"],
782 "ConfigureFail2banJail" => &["bantime", "enabled", "findtime", "maxretry", "name"],
783 "AddAuditRule" => &["key", "path", "perms"],
784 "ObtainCertificate" => &["challenge", "domain", "domains", "email"],
785 "ListJobHistory" => &[
786 "limit",
787 "status_filter",
788 "action_filter",
789 "since_hours",
790 "since",
791 ],
792 _ => return None,
793 })
794}
795
796pub(crate) fn validate_action_params(
797 action_name: &str,
798 params: &Value,
799) -> Result<(), ExecutorError> {
800 let allowed = allowed_action_params(action_name)
801 .ok_or_else(|| ExecutorError::UnknownAction(action_name.to_string()))?;
802 let object = params
803 .as_object()
804 .ok_or(ExecutorError::InvalidParam("params"))?;
805 if object.keys().any(|key| !allowed.contains(&key.as_str())) {
806 return Err(ExecutorError::InvalidParam("unknown parameter"));
807 }
808 Ok(())
809}
810
811pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
817 validate_action_params(action_name, params)?;
818 match action_name {
819 "GetSystemState" => Ok(deployment::get_system_state()),
821 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
822 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
823 "ListDeployments" => Ok(deployment::list_deployments()),
824 "UpdateSystem" => Ok(deployment::update_system()),
825 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
826 "RebootSystem" => Ok(deployment::reboot_system()),
827 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
828 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
829
830 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
832 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
833 "RebaseSystem" => {
834 let target_ref = require_str(params, "target_ref")?;
835 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
836 Ok(deployment::rebase_system(&target_ref))
837 }
838 "SetKernelArguments" => {
839 let add = str_array_or_empty(params, "add")?;
840 let remove = str_array_or_empty(params, "remove")?;
841 for arg in add.iter() {
844 validated_safe_kernel_arg(arg, "add")?;
845 }
846 for arg in remove.iter() {
849 validated_safe_kernel_arg_removal(arg, "remove")?;
850 }
851 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
852 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
853 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
854 }
855
856 "ListFlatpakRemotes" => {
862 let username = validated_username(resolve_username(params)?, "username")?;
863 Ok(flatpak::list_flatpak_remotes(&username))
864 }
865 "InstallFlatpak" => {
866 let username = validated_username(resolve_username(params)?, "username")?;
867 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
868 let remote = params
872 .get("remote")
873 .and_then(|v| v.as_str())
874 .unwrap_or("flathub");
875 let remote = validated_safe_arg(remote, "remote")?;
876 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
877 }
878 "RemoveFlatpak" => {
879 let username = validated_username(resolve_username(params)?, "username")?;
880 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
881 Ok(flatpak::remove_flatpak(&username, &app_id))
882 }
883 "SearchFlatpakApps" => {
884 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
885 Ok(flatpak::search_flatpak_apps(&term))
886 }
887 "AddFlatpakRemote" => {
888 let username = validated_username(resolve_username(params)?, "username")?;
889 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
890 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
891 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
892 }
893 "RemoveFlatpakRemote" => {
894 let username = validated_username(resolve_username(params)?, "username")?;
895 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
896 Ok(flatpak::remove_flatpak_remote(&username, &remote))
897 }
898 "GetFlatpakAppInfo" => {
899 let username = validated_username(resolve_username(params)?, "username")?;
900 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
901 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
902 }
903 "ListInstalledFlatpaks" => {
904 let username = validated_username(resolve_username(params)?, "username")?;
905 Ok(flatpak::list_installed_flatpaks(&username))
906 }
907 "UpdateFlatpak" => {
908 let username = validated_username(resolve_username(params)?, "username")?;
909 let app_id = params
912 .get("app_id")
913 .and_then(|v| v.as_str())
914 .filter(|id| !id.is_empty())
915 .map(|id| validated_safe_arg(id, "app_id"))
916 .transpose()?;
917 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
918 }
919
920 "ListContainers" => {
926 let username = validated_username(resolve_username(params)?, "username")?;
927 Ok(containers::list_containers(&username))
928 }
929 "CreateContainer" => {
930 let username = validated_username(resolve_username(params)?, "username")?;
931 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
932 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
933 Ok(containers::create_container(&username, &name, &image))
934 }
935 "StartContainer" => {
936 let username = validated_username(resolve_username(params)?, "username")?;
937 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
938 Ok(containers::start_container(&username, &name))
939 }
940 "StopContainer" => {
941 let username = validated_username(resolve_username(params)?, "username")?;
942 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
943 Ok(containers::stop_container(&username, &name))
944 }
945 "RemoveContainer" => {
946 let username = validated_username(resolve_username(params)?, "username")?;
947 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
948 Ok(containers::remove_container(&username, &name))
949 }
950 "GetContainerInfo" => {
951 let username = validated_username(resolve_username(params)?, "username")?;
952 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
953 Ok(containers::get_container_info(&username, &name))
954 }
955
956 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
958 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
959 "InstallPackages" => {
960 let pkgs = str_array_or_empty(params, "packages")?;
961 let validated: Vec<String> = pkgs
962 .iter()
963 .map(|p| validated_install_package(p, "packages"))
964 .collect::<Result<_, _>>()?;
965 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
966 Ok(layering::install_packages(&refs))
967 }
968 "RemovePackages" => {
969 let pkgs = str_array_or_empty(params, "packages")?;
970 let validated: Vec<String> = pkgs
971 .iter()
972 .map(|p| validated_install_package(p, "packages"))
973 .collect::<Result<_, _>>()?;
974 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
975 Ok(layering::remove_packages(&refs))
976 }
977 "AddLayeredPackage" => {
978 let package = validated_install_package(require_str(params, "package")?, "package")?;
979 Ok(layering::add_layered_package(&package))
980 }
981 "RemoveLayeredPackage" => {
982 let package = validated_install_package(require_str(params, "package")?, "package")?;
983 Ok(layering::remove_layered_package(&package))
984 }
985 "ReplaceLayeredPackage" => {
986 let old = validated_install_package(require_str(params, "old")?, "old")?;
987 let new = validated_install_package(require_str(params, "new")?, "new")?;
988 Ok(layering::replace_layered_package(&old, &new))
989 }
990 "RemoveBasePackage" => {
991 let package = validated_install_package(require_str(params, "package")?, "package")?;
992 Ok(layering::remove_base_package(&package))
993 }
994 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
995
996 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
998 "AddPackageRepository" => Ok(package_repos::add_package_repository(
999 validated_repo_id(params)?,
1000 validated_no_newline(params, "repo_url")?,
1001 )),
1002 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
1003 validated_repo_id(params)?,
1004 )),
1005 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
1006 validated_repo_id(params)?,
1007 )),
1008 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
1009 validated_repo_id(params)?,
1010 )),
1011
1012 "ListServices" => Ok(services::list_services()),
1014 "StartService" => {
1015 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1016 Ok(services::start_service(&unit))
1017 }
1018 "StopService" => {
1019 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1020 Ok(services::stop_service(&unit))
1021 }
1022 "RestartService" => {
1023 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1024 Ok(services::restart_service(&unit))
1025 }
1026 "SetServiceEnabled" => {
1027 let enabled = require_bool(params, "enabled")?;
1028 let unit = if enabled {
1031 validated_activatable_unit(require_str(params, "unit")?, "unit")?
1032 } else {
1033 validated_unit_name(require_str(params, "unit")?, "unit")?
1034 };
1035 Ok(services::set_service_enabled(&unit, enabled))
1036 }
1037 "MaskService" => {
1038 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1039 Ok(services::mask_service(&unit))
1040 }
1041 "UnmaskService" => {
1042 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1043 Ok(services::unmask_service(&unit))
1044 }
1045 "GetServiceLogs" => {
1046 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1047 Ok(services::get_service_logs(&unit))
1048 }
1049 "GetServiceStatus" => {
1050 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1051 Ok(services::get_service_status(&unit))
1052 }
1053 "ReloadService" => {
1054 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1055 Ok(services::reload_service(&unit))
1056 }
1057 "ListTimers" => Ok(services::list_timers()),
1058 "ReloadDaemon" => Ok(services::reload_daemon()),
1059 "CreateScheduledJob" => {
1060 let name = require_str(params, "name")?;
1063 if services::scheduled_job_unit_paths(name).is_none() {
1064 return Err(ExecutorError::InvalidParam("name"));
1065 }
1066 let command = require_str(params, "command")?;
1069 if command.is_empty()
1070 || command.len() > MAX_SCHEDULED_COMMAND_LEN
1071 || command.chars().any(|c| c.is_control())
1072 {
1073 return Err(ExecutorError::InvalidParam("command"));
1074 }
1075 let schedule = require_str(params, "schedule")?;
1078 if schedule.is_empty()
1079 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
1080 || !schedule.chars().all(|c| {
1081 c.is_ascii_alphanumeric()
1082 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
1083 })
1084 {
1085 return Err(ExecutorError::InvalidParam("schedule"));
1086 }
1087 Ok(services::create_scheduled_job(name, command, schedule))
1088 }
1089 "GetServiceResourceLimits" => {
1090 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1091 Ok(services::get_service_resource_limits(&unit))
1092 }
1093 "SetServiceResourceLimits" => {
1094 let unit = validated_resource_limit_unit(require_str(params, "unit")?, "unit")?;
1098 let mut assignments = Vec::new();
1101 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
1102 assignments.push(format!("MemoryMax={v}"));
1103 }
1104 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
1105 assignments.push(format!("MemoryHigh={v}"));
1106 }
1107 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
1108 assignments.push(format!("CPUQuota={v}"));
1109 }
1110 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
1111 assignments.push(format!("TasksMax={v}"));
1112 }
1113 if assignments.is_empty() {
1114 return Err(ExecutorError::MissingParam("memory_max"));
1115 }
1116 Ok(services::set_service_resource_limits(&unit, &assignments))
1117 }
1118
1119 "ListToolboxes" => {
1123 let username = validated_username(resolve_username(params)?, "username")?;
1124 Ok(toolbox::list_toolboxes(&username))
1125 }
1126 "CreateToolbox" => {
1127 let username = validated_username(resolve_username(params)?, "username")?;
1128 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1129 let image = params
1130 .get("image")
1131 .and_then(|v| v.as_str())
1132 .map(|img| validated_safe_arg(img, "image"))
1133 .transpose()?;
1134 let release = params
1135 .get("release")
1136 .and_then(|v| v.as_str())
1137 .map(|r| validated_safe_arg(r, "release"))
1138 .transpose()?;
1139 Ok(toolbox::create_toolbox(
1140 &username,
1141 &name,
1142 release.as_deref(),
1143 image.as_deref(),
1144 ))
1145 }
1146 "RemoveToolbox" => {
1147 let username = validated_username(resolve_username(params)?, "username")?;
1148 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1149 Ok(toolbox::remove_toolbox(&username, &name))
1150 }
1151
1152 "GetDateTime" => Ok(identity::get_datetime()),
1154 "SetHostname" => {
1155 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
1156 Ok(identity::set_hostname(&hostname))
1157 }
1158 "SetTimezone" => {
1159 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
1160 Ok(identity::set_timezone(&timezone))
1161 }
1162 "SetLocale" => {
1163 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
1164 Ok(identity::set_locale(&locale))
1165 }
1166 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
1167
1168 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
1170
1171 "ListProcesses" => Ok(processes::list_processes_spec()),
1173 "SignalProcess" => {
1174 let pid = params
1176 .get("pid")
1177 .and_then(|v| {
1178 v.as_u64()
1179 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
1180 })
1181 .ok_or(ExecutorError::MissingParam("pid"))?;
1182 if pid < 2 || pid > u32::MAX as u64 {
1185 return Err(ExecutorError::InvalidParam("pid"));
1186 }
1187 let signal = validated_kill_signal(
1188 params
1189 .get("signal")
1190 .and_then(|v| v.as_str())
1191 .unwrap_or("TERM"),
1192 )?;
1193 Ok(processes::signal_process(pid as u32, signal))
1194 }
1195
1196 "GetJournalLog" => {
1198 let unit = optional_validated(params, "unit", validated_unit_name)?;
1199 let priority = optional_validated(params, "priority", validated_journal_priority)?;
1200 let since = optional_validated(params, "since", validated_journal_time)?;
1201 let until = optional_validated(params, "until", validated_journal_time)?;
1202 let grep = optional_validated(params, "grep", validated_journal_grep)?;
1203 let lines = params
1206 .get("lines")
1207 .and_then(|v| v.as_u64())
1208 .unwrap_or(100)
1209 .clamp(1, MAX_JOURNAL_LINES) as u32;
1210 let boot = params
1211 .get("boot")
1212 .and_then(|v| v.as_bool())
1213 .unwrap_or(false);
1214 let kernel = params
1215 .get("kernel")
1216 .and_then(|v| v.as_bool())
1217 .unwrap_or(false);
1218 Ok(journald::get_journal_log(&journald::JournalQuery {
1219 lines,
1220 unit: unit.as_deref(),
1221 priority: priority.as_deref(),
1222 boot,
1223 kernel,
1224 since: since.as_deref(),
1225 until: until.as_deref(),
1226 grep: grep.as_deref(),
1227 }))
1228 }
1229 "VacuumJournal" => {
1230 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1232 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1233 match (size_mb, retain_days) {
1234 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1235 Ok(journald::vacuum_journal_by_size(mb as u32))
1236 }
1237 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1238 Ok(journald::vacuum_journal_by_time(days as u32))
1239 }
1240 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1241 _ => Err(ExecutorError::InvalidParam("size_mb")),
1243 }
1244 }
1245
1246 "GetLvmReport" => Ok(lvm::get_lvm_report()),
1248 "ExtendLogicalVolume" => {
1249 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1250 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1251 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1252 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1253 }
1254 "CreateLogicalVolume" => {
1255 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1256 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1257 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1258 Ok(lvm::create_logical_volume(&vg, &name, &size))
1259 }
1260 "CreateLvSnapshot" => {
1261 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1262 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1263 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1264 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1265 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1266 }
1267
1268 "GetSysctl" => {
1270 let key = optional_validated(params, "key", validated_sysctl_key)?;
1272 Ok(sysctl::get_sysctl(key.as_deref()))
1273 }
1274 "SetSysctl" => {
1275 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1276 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1277 Ok(sysctl::set_sysctl(&key, &value))
1278 }
1279
1280 "GetMounts" => Ok(mounts::get_mounts()),
1282 "AddMount" => {
1283 let device = validated_mount_device(require_str(params, "device")?, "device")?;
1284 let mountpoint =
1285 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1286 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1287 let options = optional_validated(params, "options", validated_mount_options)?;
1288 Ok(mounts::add_mount(
1289 &device,
1290 &mountpoint,
1291 &fstype,
1292 options.as_deref(),
1293 ))
1294 }
1295 "RemoveMount" => {
1296 let mountpoint =
1297 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1298 Ok(mounts::remove_mount(&mountpoint))
1299 }
1300 "AddSwap" => {
1301 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1302 let size_mb = require_u32(params, "size_mb")?;
1303 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1305 return Err(ExecutorError::InvalidParam("size_mb"));
1306 }
1307 Ok(mounts::add_swap(&file, size_mb))
1308 }
1309 "RemoveSwap" => {
1310 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1311 Ok(mounts::remove_swap(&file))
1312 }
1313
1314 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1316 "GrantSudoAccess" => {
1317 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1318 let user = validated_username(require_str(params, "user")?, "user")?;
1319 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1320 let runas = match params
1322 .get("runas")
1323 .and_then(|v| v.as_str())
1324 .filter(|s| !s.is_empty())
1325 {
1326 Some("ALL") => Some("ALL".to_string()),
1327 Some(u) => Some(validated_username(u, "runas")?),
1328 None => None,
1329 };
1330 let nopasswd = params
1331 .get("nopasswd")
1332 .and_then(|v| v.as_bool())
1333 .unwrap_or(false);
1334 let shell_equivalent = shell_equivalent_sudo_command(&commands);
1353 if (commands == "ALL" || shell_equivalent.is_some()) && nopasswd {
1354 return Err(ExecutorError::InvalidParam("commands"));
1355 }
1356 Ok(sudoers::grant_sudo_access(
1357 &name,
1358 &user,
1359 &commands,
1360 runas.as_deref(),
1361 nopasswd,
1362 ))
1363 }
1364 "RevokeSudoAccess" => {
1365 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1366 Ok(sudoers::revoke_sudo_access(&name))
1367 }
1368
1369 "GetAptPins" => {
1371 let package = optional_validated(params, "package", validated_apt_package)?;
1372 Ok(apt_preferences::get_apt_pins(package.as_deref()))
1373 }
1374 "SetAptPin" => {
1375 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1376 let package = validated_apt_package(require_str(params, "package")?, "package")?;
1377 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1378 let priority = params
1379 .get("priority")
1380 .and_then(|v| v.as_i64())
1381 .ok_or(ExecutorError::MissingParam("priority"))?;
1382 if !(-1..=1000).contains(&priority) {
1383 return Err(ExecutorError::InvalidParam("priority"));
1384 }
1385 Ok(apt_preferences::set_apt_pin(
1386 &name, &package, &pin, priority,
1387 ))
1388 }
1389 "RemoveAptPin" => {
1390 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1391 Ok(apt_preferences::remove_apt_pin(&name))
1392 }
1393
1394 "GetLogrotateStatus" => {
1396 let config = optional_validated(params, "config", validated_log_path)?;
1397 Ok(logging::get_logrotate_status(config.as_deref()))
1398 }
1399 "ConfigureLogRotation" => {
1400 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1401 let path = validated_log_path(require_str(params, "path")?, "path")?;
1402 let frequency = require_str(params, "frequency")?;
1403 if !matches!(frequency, "daily" | "weekly" | "monthly") {
1404 return Err(ExecutorError::InvalidParam("frequency"));
1405 }
1406 let rotate = require_u32(params, "rotate")?;
1407 if rotate > 1000 {
1408 return Err(ExecutorError::InvalidParam("rotate"));
1409 }
1410 let compress = params
1411 .get("compress")
1412 .and_then(|v| v.as_bool())
1413 .unwrap_or(false);
1414 Ok(logging::configure_log_rotation(
1415 &name, &path, frequency, rotate, compress,
1416 ))
1417 }
1418 "RemoveLogRotation" => {
1419 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1420 Ok(logging::remove_log_rotation(&name))
1421 }
1422 "ConfigureRemoteSyslog" => {
1423 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1424 let port = require_u32(params, "port")?;
1425 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1426 return Err(ExecutorError::InvalidParam("port"));
1427 }
1428 let protocol = require_str(params, "protocol")?;
1429 if !matches!(protocol, "tcp" | "udp") {
1430 return Err(ExecutorError::InvalidParam("protocol"));
1431 }
1432 Ok(logging::configure_remote_syslog(
1433 &host,
1434 port as u16,
1435 protocol,
1436 ))
1437 }
1438 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1439
1440 "GetPasswordAging" => {
1442 let user = validated_username(require_str(params, "user")?, "user")?;
1443 Ok(pam::get_password_aging(&user))
1444 }
1445 "SetPasswordAging" => {
1446 let user = validated_username(require_str(params, "user")?, "user")?;
1447 let mut flags = Vec::new();
1449 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1450 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1451 if n > MAX_PASSWORD_AGE_DAYS {
1452 return Err(ExecutorError::InvalidParam(key));
1453 }
1454 flags.push(opt.to_string());
1455 flags.push(n.to_string());
1456 }
1457 }
1458 if flags.is_empty() {
1459 return Err(ExecutorError::MissingParam("max_days"));
1460 }
1461 Ok(pam::set_password_aging(&user, &flags))
1462 }
1463 "SetPasswordPolicy" => {
1464 let mut extra = Vec::new();
1467 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1468 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1469 return Err(ExecutorError::InvalidParam("minlen"));
1470 }
1471 extra.push("--minlen".to_string());
1472 extra.push(n.to_string());
1473 }
1474 for (key, flag) in [
1475 ("dcredit", "--dcredit"),
1476 ("ucredit", "--ucredit"),
1477 ("lcredit", "--lcredit"),
1478 ("ocredit", "--ocredit"),
1479 ] {
1480 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1481 if !(-64..=64).contains(&n) {
1482 return Err(ExecutorError::InvalidParam(key));
1483 }
1484 extra.push(flag.to_string());
1485 extra.push(n.to_string());
1486 }
1487 }
1488 if extra.is_empty() {
1489 return Err(ExecutorError::MissingParam("minlen"));
1490 }
1491 Ok(pam::set_password_policy(&extra))
1492 }
1493 "SetAccountLockout" => {
1494 let mut extra = Vec::new();
1496 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1497 if !(1..=1000).contains(&n) {
1498 return Err(ExecutorError::InvalidParam("deny"));
1499 }
1500 extra.push("--deny".to_string());
1501 extra.push(n.to_string());
1502 }
1503 for (key, flag) in [
1504 ("unlock_time", "--unlock-time"),
1505 ("fail_interval", "--fail-interval"),
1506 ] {
1507 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1508 if n > MAX_LOCKOUT_WINDOW_SECS {
1509 return Err(ExecutorError::InvalidParam(key));
1510 }
1511 extra.push(flag.to_string());
1512 extra.push(n.to_string());
1513 }
1514 }
1515 if extra.is_empty() {
1516 return Err(ExecutorError::MissingParam("deny"));
1517 }
1518 Ok(pam::set_account_lockout(&extra))
1519 }
1520
1521 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1523 "GetHostState" => Ok(system_info::get_host_state()),
1524
1525 "GetFirewallState" => Ok(network::get_firewall_state()),
1527 "GetNetworkStatus" => Ok(network::get_network_status()),
1528 "GetListeningPorts" => Ok(network::get_listening_ports()),
1529 "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1530 "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1531 "ConfigureWifi" => {
1532 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1533 let password = params
1535 .get("password")
1536 .and_then(|v| v.as_str())
1537 .filter(|p| !p.is_empty())
1538 .map(|p| validated_safe_arg(p, "password"))
1539 .transpose()?;
1540 Ok(network::configure_wifi(&ssid, password.as_deref()))
1541 }
1542 "SetDnsServers" => {
1543 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1544 let servers = str_array_or_empty(params, "servers")?;
1545 let validated: Vec<String> = servers
1546 .iter()
1547 .map(|s| validated_safe_arg(s, "servers"))
1548 .collect::<Result<_, _>>()?;
1549 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1550 Ok(network::set_dns_servers(&interface, &refs))
1551 }
1552 "ConfigureFirewall" => {
1553 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1554 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1555 Ok(network::configure_firewall(
1556 &zone,
1557 &service,
1558 require_bool(params, "enabled")?,
1559 ))
1560 }
1561
1562 "ListUsers" => Ok(users::list_users()),
1564 "ListGroups" => Ok(users::list_groups()),
1565 "CreateUser" => {
1566 let username = validated_username(resolve_username(params)?, "username")?;
1567 let shell = params
1568 .get("shell")
1569 .and_then(|v| v.as_str())
1570 .map(|s| validated_safe_arg(s, "shell"))
1571 .transpose()?;
1572 let home = params
1573 .get("home")
1574 .and_then(|v| v.as_str())
1575 .map(|h| validated_home_dir(h, "home"))
1576 .transpose()?;
1577 Ok(users::create_user(
1578 &username,
1579 shell.as_deref(),
1580 home.as_deref(),
1581 ))
1582 }
1583 "DeleteUser" => {
1584 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1585 Ok(users::delete_user(&username))
1586 }
1587 "AddUserToGroup" => {
1588 let username = validated_username(resolve_username(params)?, "username")?;
1589 let group = validated_group(require_str(params, "group")?, "group")?;
1590 Ok(users::add_user_to_group(&username, &group))
1591 }
1592 "RemoveUserFromGroup" => {
1593 let username = validated_username(resolve_username(params)?, "username")?;
1594 let group = validated_group(require_str(params, "group")?, "group")?;
1595 Ok(users::remove_user_from_group(&username, &group))
1596 }
1597 "CreateGroup" => {
1598 let group = validated_group(require_str(params, "group")?, "group")?;
1599 let system = params
1600 .get("system")
1601 .and_then(|v| v.as_bool())
1602 .unwrap_or(false);
1603 Ok(users::create_group(&group, system))
1604 }
1605 "DeleteGroup" => {
1606 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1607 Ok(users::delete_group(&group))
1608 }
1609 "LockUserAccount" => {
1610 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1611 Ok(users::lock_user_account(&username))
1612 }
1613 "UnlockUserAccount" => {
1614 let username = validated_username(resolve_username(params)?, "username")?;
1615 Ok(users::unlock_user_account(&username))
1616 }
1617
1618 "GetAuthorizedKeys" => {
1620 let username = validated_username(resolve_username(params)?, "username")?;
1621 Ok(ssh::get_authorized_keys(&username))
1622 }
1623 "AddAuthorizedKey" => {
1624 let username = validated_username(resolve_username(params)?, "username")?;
1625 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1626 Ok(ssh::add_authorized_key(&username, &public_key))
1627 }
1628 "RemoveAuthorizedKey" => {
1629 let username = validated_username(resolve_username(params)?, "username")?;
1630 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1631 Ok(ssh::remove_authorized_key(&username, &public_key))
1632 }
1633 "SetSshdOption" => {
1634 let option = require_str(params, "option")?;
1638 let value = require_str(params, "value")?;
1639 let allowed_values: &[&str] = match option {
1640 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1641 "PasswordAuthentication"
1642 | "PubkeyAuthentication"
1643 | "X11Forwarding"
1644 | "PermitEmptyPasswords" => &["yes", "no"],
1645 _ => return Err(ExecutorError::InvalidParam("option")),
1646 };
1647 if !allowed_values.contains(&value) {
1648 return Err(ExecutorError::InvalidParam("value"));
1649 }
1650 Ok(ssh::set_sshd_option(option, value))
1651 }
1652
1653 "AptUpdate" => Ok(apt::apt_update()),
1655 "AptUpgrade" => Ok(apt::apt_upgrade()),
1656 "AptInstall" => {
1657 let package = validated_install_package(require_str(params, "package")?, "package")?;
1658 Ok(apt::apt_install(&package))
1659 }
1660 "AptRemove" => {
1661 let package = validated_install_package(require_str(params, "package")?, "package")?;
1662 Ok(apt::apt_remove(&package))
1663 }
1664 "AptPurge" => {
1665 let package = validated_install_package(require_str(params, "package")?, "package")?;
1666 Ok(apt::apt_purge(&package))
1667 }
1668 "AptAutoremove" => Ok(apt::apt_autoremove()),
1669 "AptHold" => {
1670 let package = validated_install_package(require_str(params, "package")?, "package")?;
1671 Ok(apt::apt_hold(&package))
1672 }
1673 "AptUnhold" => {
1674 let package = validated_install_package(require_str(params, "package")?, "package")?;
1675 Ok(apt::apt_unhold(&package))
1676 }
1677 "AptSearch" => {
1678 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1679 Ok(apt::apt_search(&term))
1680 }
1681 "AptListInstalled" => Ok(apt::apt_list_installed()),
1682 "AptShow" => {
1683 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1684 Ok(apt::apt_show(&package))
1685 }
1686 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1687 "AptHistoryList" => Ok(apt::apt_history_list()),
1688 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1689 params, "enabled",
1690 )?)),
1691
1692 "AddPpa" => {
1694 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1695 Ok(ppa::add_ppa(&name))
1696 }
1697 "RemovePpa" => {
1698 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1699 Ok(ppa::remove_ppa(&name))
1700 }
1701
1702 "SnapInstall" => {
1704 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1705 let channel = params
1706 .get("channel")
1707 .and_then(|v| v.as_str())
1708 .filter(|s| !s.is_empty())
1709 .map(|s| validated_safe_arg(s, "channel"))
1710 .transpose()?;
1711 let auto_update = params
1712 .get("auto_update")
1713 .and_then(|v| v.as_bool())
1714 .unwrap_or(false);
1715 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1716 snap::SnapInstallError::InvalidArg { param, .. } => {
1717 ExecutorError::InvalidParam(param)
1718 }
1719 })
1720 }
1721 "SnapRemove" => {
1722 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1723 Ok(snap::snap_remove(&name))
1724 }
1725 "SnapRefresh" => {
1726 let name = params
1727 .get("name")
1728 .and_then(|v| v.as_str())
1729 .filter(|s| !s.is_empty())
1730 .map(|s| validated_safe_arg(s, "name"))
1731 .transpose()?;
1732 Ok(snap::snap_refresh(name.as_deref()))
1733 }
1734 "SnapHold" => {
1735 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1736 Ok(snap::snap_hold(&name))
1737 }
1738 "SnapUnhold" => {
1739 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1740 Ok(snap::snap_unhold(&name))
1741 }
1742 "SnapList" => Ok(snap::snap_list()),
1743 "SnapInfo" => {
1744 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1745 Ok(snap::snap_info(&name))
1746 }
1747 "SnapRevert" => {
1748 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1749 Ok(snap::snap_revert(&name))
1750 }
1751 "SnapClassicInstall" => {
1752 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1753 Ok(snap::snap_classic_install(&name))
1754 }
1755
1756 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1758 "GrubSetKargs" => {
1759 let append: Vec<String> = params
1760 .get("append")
1761 .and_then(|v| v.as_array())
1762 .map(|arr| {
1763 arr.iter()
1764 .filter_map(|v| v.as_str())
1765 .map(String::from)
1766 .collect()
1767 })
1768 .unwrap_or_default();
1769 let delete: Vec<String> = params
1770 .get("delete")
1771 .and_then(|v| v.as_array())
1772 .map(|arr| {
1773 arr.iter()
1774 .filter_map(|v| v.as_str())
1775 .map(String::from)
1776 .collect()
1777 })
1778 .unwrap_or_default();
1779 for a in &append {
1788 validated_safe_arg(a, "append")?;
1789 validated_safe_kernel_arg(a, "append")?;
1790 }
1791 for d in &delete {
1792 validated_safe_arg(d, "delete")?;
1793 validated_safe_kernel_arg_removal(d, "delete")?;
1794 }
1795 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1798 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1799 grub::grub_set_kargs(&append_refs, &delete_refs)
1800 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1801 }
1802
1803 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1805
1806 "UfwEnable" => Ok(ufw::ufw_enable()),
1808 "UfwDisable" => Ok(ufw::ufw_disable()),
1809 "UfwAllow" => {
1810 let port_or_service = validated_port_or_service(
1811 require_str(params, "port_or_service")?,
1812 "port_or_service",
1813 )?;
1814 Ok(ufw::ufw_allow(&port_or_service))
1815 }
1816 "UfwDeny" => {
1817 let port_or_service = validated_port_or_service(
1818 require_str(params, "port_or_service")?,
1819 "port_or_service",
1820 )?;
1821 Ok(ufw::ufw_deny(&port_or_service))
1822 }
1823 "UfwReset" => Ok(ufw::ufw_reset()),
1824 "UfwStatus" => {
1825 let numbered = match params.get("numbered") {
1826 None => false,
1827 Some(value) => value
1828 .as_bool()
1829 .ok_or(ExecutorError::InvalidParam("numbered"))?,
1830 };
1831 Ok(ufw::ufw_status_with_numbered(numbered))
1832 }
1833
1834 "DistroboxList" => Ok(distrobox::distrobox_list()),
1836 "DistroboxCreate" => {
1837 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1838 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1839 Ok(distrobox::distrobox_create(&name, &image))
1840 }
1841 "DistroboxRemove" => {
1842 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1843 Ok(distrobox::distrobox_remove(&name))
1844 }
1845
1846 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1848 "NetplanApply" => Ok(netplan::netplan_apply()),
1849 "NetplanSet" => {
1850 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1851 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1852 Ok(netplan::netplan_set(&key, &value))
1853 }
1854 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1855
1856 "UfwDeleteRule" => {
1858 let rule_number = require_positive_u32(params, "rule_number")?;
1859 ufw::ufw_delete_rule(rule_number)
1860 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1861 }
1862 "UfwLimit" => {
1863 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1864 Ok(ufw::ufw_limit(&target))
1865 }
1866
1867 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1869 "ProAttach" => {
1870 let token = require_str(params, "token")?;
1872 let token = validated_safe_arg(token, "token")?;
1874 Ok(ubuntu_pro::pro_attach(&token))
1875 }
1876 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1877 "EnableProService" => {
1878 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1879 Ok(ubuntu_pro::enable_pro_service(&service))
1880 }
1881 "DisableProService" => {
1882 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1883 Ok(ubuntu_pro::disable_pro_service(&service))
1884 }
1885
1886 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1888
1889 "MultipassList" => Ok(multipass::multipass_list()),
1891
1892 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1894
1895 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1897 "ResolvectlSetDns" => {
1898 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1899 let raw_servers: Vec<String> = params
1900 .get("servers")
1901 .and_then(|v| v.as_array())
1902 .map(|arr| {
1903 arr.iter()
1904 .filter_map(|v| v.as_str())
1905 .map(String::from)
1906 .collect()
1907 })
1908 .unwrap_or_default();
1909 if raw_servers.is_empty() {
1910 return Err(ExecutorError::MissingParam("servers"));
1911 }
1912 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1916 for s in &raw_servers {
1917 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1918 param: "servers",
1919 value: s.clone(),
1920 })?;
1921 parsed_servers.push(addr);
1922 }
1923 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1924 }
1925
1926 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1928 "AppArmorEnforce" => {
1929 let profile_path =
1930 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1931 Ok(apparmor::apparmor_enforce(&profile_path))
1932 }
1933 "AppArmorComplain" => {
1934 let profile_path =
1935 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1936 Ok(apparmor::apparmor_complain(&profile_path))
1937 }
1938
1939 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1941
1942 "UbuntuInstallFlatpak" => {
1944 let username = validated_username(resolve_username(params)?, "username")?;
1945 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1946 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1947 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1948 }
1949 "UbuntuRemoveFlatpak" => {
1950 let username = validated_username(resolve_username(params)?, "username")?;
1951 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1952 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1953 }
1954 "UbuntuUpdateFlatpak" => {
1955 let username = validated_username(resolve_username(params)?, "username")?;
1956 let app_id = params
1957 .get("app_id")
1958 .and_then(|v| v.as_str())
1959 .filter(|s| !s.is_empty())
1960 .map(|s| validated_safe_arg(s, "app_id"))
1961 .transpose()?;
1962 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1963 }
1964 "UbuntuListFlatpaks" => {
1965 let username = validated_username(resolve_username(params)?, "username")?;
1966 Ok(flatpak::ubuntu_list_flatpaks(&username))
1967 }
1968
1969 "Fail2banStatus" => {
1971 let jail = params
1972 .get("jail")
1973 .and_then(|v| v.as_str())
1974 .filter(|s| !s.is_empty())
1975 .map(|s| validated_safe_arg(s, "jail"))
1976 .transpose()?;
1977 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1978 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1979 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1980 })
1981 }
1982 "Fail2banBanIp" => {
1983 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1984 let ip = require_str(params, "ip")?;
1985 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1986 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1987 param: "ip",
1988 value: v,
1989 },
1990 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1991 })
1992 }
1993 "Fail2banUnbanIp" => {
1994 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1995 let ip = require_str(params, "ip")?;
1996 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1997 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1998 param: "ip",
1999 value: v,
2000 },
2001 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
2002 })
2003 }
2004 "ConfigureFail2banJail" => {
2005 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
2006 let mut extra = Vec::new();
2007 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
2008 extra.push("--enabled".to_string());
2009 extra.push(if n { "true" } else { "false" }.to_string());
2010 }
2011 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
2012 if !(1..=100).contains(&n) {
2013 return Err(ExecutorError::InvalidParam("maxretry"));
2014 }
2015 extra.push("--maxretry".to_string());
2016 extra.push(n.to_string());
2017 }
2018 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
2019 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
2020 if n > MAX_FAIL2BAN_WINDOW_SECS {
2021 return Err(ExecutorError::InvalidParam(key));
2022 }
2023 extra.push(flag.to_string());
2024 extra.push(n.to_string());
2025 }
2026 }
2027 if extra.is_empty() {
2028 return Err(ExecutorError::MissingParam("maxretry"));
2029 }
2030 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
2031 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
2032 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
2033 })
2034 }
2035
2036 "GetAuditRules" => Ok(auditd::get_audit_rules()),
2038 "AddAuditRule" => {
2039 let path = validated_audit_path(require_str(params, "path")?, "path")?;
2040 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
2041 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2042 Ok(auditd::add_audit_rule(&path, &perms, &key))
2043 }
2044 "RemoveAuditRule" => {
2045 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2046 Ok(auditd::remove_audit_rule(&key))
2047 }
2048
2049 "GetCertificates" => Ok(certbot::get_certificates()),
2051 "ObtainCertificate" => {
2052 let mut domains = Vec::new();
2054 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
2055 for d in arr {
2056 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
2057 domains.push(validated_domain(s, "domains")?);
2058 }
2059 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
2060 domains.push(validated_domain(d, "domain")?);
2061 }
2062 if domains.is_empty() {
2063 return Err(ExecutorError::MissingParam("domains"));
2064 }
2065 let email = validated_email(require_str(params, "email")?, "email")?;
2066 let challenge = params
2067 .get("challenge")
2068 .and_then(|v| v.as_str())
2069 .unwrap_or("standalone");
2070 if !matches!(challenge, "standalone" | "nginx" | "apache") {
2071 return Err(ExecutorError::InvalidParam("challenge"));
2072 }
2073 Ok(certbot::obtain_certificate(&domains, &email, challenge))
2074 }
2075 "RenewCertificates" => Ok(certbot::renew_certificates()),
2076
2077 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
2078 }
2079}
2080
2081pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
2087 match &spec.mechanism {
2088 ActionMechanism::Command { program, args } => {
2089 let mut child = tokio::process::Command::new(program)
2093 .args(args)
2094 .stdin(Stdio::null())
2095 .process_group(0)
2098 .stdout(Stdio::piped())
2099 .stderr(Stdio::piped())
2100 .spawn()?;
2101
2102 let stdout_h = child.stdout.take().expect("stdout was piped");
2107 let stderr_h = child.stderr.take().expect("stderr was piped");
2108 let stdout_task = tokio::spawn(async move {
2109 let mut buf = Vec::new();
2110 BufReader::new(stdout_h)
2111 .read_to_end(&mut buf)
2112 .await
2113 .map(|_| buf)
2114 });
2115 let stderr_task = tokio::spawn(async move {
2116 let mut buf = Vec::new();
2117 BufReader::new(stderr_h)
2118 .read_to_end(&mut buf)
2119 .await
2120 .map(|_| buf)
2121 });
2122
2123 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
2124 Ok(Ok(status)) => status,
2125 Ok(Err(err)) => {
2129 return Err(kill_and_reap(
2130 &mut child,
2131 program,
2132 args,
2133 StopCause::Unreadable(err),
2134 )
2135 .await)
2136 }
2137 Err(_) => {
2138 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
2139 }
2140 };
2141 let join =
2142 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
2143 let stdout = stdout_task.await.map_err(join)??;
2144 let stderr = stderr_task.await.map_err(join)??;
2145
2146 Ok(ExecutionOutput {
2147 stdout: String::from_utf8_lossy(&stdout).into_owned(),
2148 stderr: String::from_utf8_lossy(&stderr).into_owned(),
2149 exit_code: status.code().unwrap_or(-1),
2150 })
2151 }
2152 ActionMechanism::FileScan { path } => {
2153 let mut entries = tokio::fs::read_dir(path).await?;
2154 let mut names = Vec::new();
2155 while let Some(entry) = entries.next_entry().await? {
2156 names.push(entry.file_name().to_string_lossy().into_owned());
2157 }
2158 names.sort();
2159 Ok(ExecutionOutput {
2160 stdout: names.join("\n"),
2161 stderr: String::new(),
2162 exit_code: 0,
2163 })
2164 }
2165 ActionMechanism::FileWrite { path, content } => {
2166 if let Some(parent) = std::path::Path::new(path).parent() {
2167 tokio::fs::create_dir_all(parent).await?;
2168 }
2169 tokio::fs::write(path, content).await?;
2170 Ok(ExecutionOutput {
2171 stdout: String::new(),
2172 stderr: String::new(),
2173 exit_code: 0,
2174 })
2175 }
2176 ActionMechanism::FilePatch {
2177 path,
2178 search,
2179 replace,
2180 } => {
2181 let content = tokio::fs::read_to_string(path).await?;
2182 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
2183 if patched == content && !search.is_empty() {
2184 return Ok(ExecutionOutput {
2185 stdout: String::new(),
2186 stderr: format!("search string not found in file: {}", path),
2187 exit_code: 1,
2188 });
2189 }
2190 tokio::fs::write(path, patched).await?;
2191 Ok(ExecutionOutput {
2192 stdout: String::new(),
2193 stderr: String::new(),
2194 exit_code: 0,
2195 })
2196 }
2197 ActionMechanism::FileDelete { path } => {
2198 tokio::fs::remove_file(path).await?;
2199 Ok(ExecutionOutput {
2200 stdout: String::new(),
2201 stderr: String::new(),
2202 exit_code: 0,
2203 })
2204 }
2205 }
2206}
2207
2208fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
2209 match params.get(key) {
2210 None => Err(ExecutorError::MissingParam(key)),
2211 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
2212 }
2213}
2214
2215fn optional_validated<F>(
2219 params: &Value,
2220 key: &'static str,
2221 validator: F,
2222) -> Result<Option<String>, ExecutorError>
2223where
2224 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2225{
2226 match params
2227 .get(key)
2228 .and_then(|v| v.as_str())
2229 .filter(|s| !s.is_empty())
2230 {
2231 Some(s) => Ok(Some(validator(s, key)?)),
2232 None => Ok(None),
2233 }
2234}
2235
2236fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2246 params
2247 .get("username")
2248 .or_else(|| params.get("user"))
2249 .ok_or(ExecutorError::MissingParam("username"))
2250 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2251}
2252
2253fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2257 let id = require_str(params, "repo_id")?;
2258 let valid = !id.is_empty()
2259 && id
2260 .chars()
2261 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2262 if valid {
2263 Ok(id)
2264 } else {
2265 Err(ExecutorError::InvalidParam("repo_id"))
2266 }
2267}
2268
2269fn validated_no_newline<'a>(
2272 params: &'a Value,
2273 key: &'static str,
2274) -> Result<&'a str, ExecutorError> {
2275 let val = require_str(params, key)?;
2276 if val.contains('\n') || val.contains('\r') {
2277 Err(ExecutorError::InvalidParam(key))
2278 } else {
2279 Ok(val)
2280 }
2281}
2282
2283fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2287 const MAX_LEN: usize = 8192;
2288 const ALLOWED_PREFIXES: &[&str] = &[
2289 "ssh-rsa",
2290 "ssh-ed25519",
2291 "ssh-ed25519-sk",
2292 "ecdsa-sha2-nistp256",
2293 "ecdsa-sha2-nistp384",
2294 "ecdsa-sha2-nistp521",
2295 "sk-ssh-ed25519",
2296 "sk-ecdsa-sha2-nistp256",
2297 ];
2298
2299 if s.is_empty() || s.len() > MAX_LEN {
2300 return Err(ExecutorError::InvalidParam("public_key"));
2301 }
2302 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2303 return Err(ExecutorError::InvalidParam("public_key"));
2304 }
2305 if s.chars().any(|c| {
2320 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2321 || !c.is_ascii()
2322 || c.is_ascii_control()
2323 }) {
2324 return Err(ExecutorError::InvalidParam("public_key"));
2325 }
2326 Ok(s.to_string())
2327}
2328
2329fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2330 match params.get(key) {
2331 None => Err(ExecutorError::MissingParam(key)),
2332 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2333 }
2334}
2335
2336fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2337 match params.get(key) {
2338 None => Err(ExecutorError::MissingParam(key)),
2339 Some(v) => {
2340 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2341 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2342 }
2343 }
2344}
2345
2346fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2350 let n = require_u32(params, key)?;
2351 if n == 0 {
2352 return Err(ExecutorError::InvalidParam(key));
2353 }
2354 Ok(n)
2355}
2356
2357fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2361 match params.get(key) {
2362 None | Some(Value::Null) => Ok(vec![]),
2363 Some(Value::Array(arr)) => arr
2364 .iter()
2365 .map(|v| {
2366 v.as_str()
2367 .map(String::from)
2368 .ok_or(ExecutorError::InvalidParam(key))
2369 })
2370 .collect(),
2371 _ => Err(ExecutorError::InvalidParam(key)),
2372 }
2373}
2374
2375fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2410 const BLOCKED_PREFIXES: &[&str] = &[
2411 "init=",
2412 "selinux=0",
2413 "enforcing=0",
2414 "security=",
2415 "module_blacklist=",
2416 "apparmor=0",
2417 "mitigations=off",
2418 "lockdown=",
2419 "pti=off",
2420 "systemd.debug_shell",
2429 "systemd.debug-shell",
2430 "rd.systemd.debug_shell",
2431 "rd.systemd.debug-shell",
2432 ];
2433 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2434 use crate::actions::validate::ROOT_SHELL_UNITS as BLOCKED_UNIT_PREFIXES;
2440
2441 let lower = arg.to_lowercase();
2442 let base = lower.split('=').next().unwrap_or(&lower);
2444
2445 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2446 return Err(ExecutorError::InvalidParam(param));
2447 }
2448 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2449 return Err(ExecutorError::InvalidParam(param));
2450 }
2451 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2453 if BLOCKED_UNIT_PREFIXES
2454 .iter()
2455 .any(|u| unit_val.starts_with(u))
2456 {
2457 return Err(ExecutorError::InvalidParam(param));
2458 }
2459 }
2460 if BLOCKED_EXACT.contains(&base) {
2462 return Err(ExecutorError::InvalidParam(param));
2463 }
2464 Ok(())
2465}
2466
2467fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2483 const WEAKENING: &[&str] = &[
2486 "selinux=0",
2487 "enforcing=0",
2488 "apparmor=0",
2489 "mitigations=off",
2490 "pti=off",
2491 "lockdown=none",
2492 "debugfs=on",
2493 "iommu=off",
2494 "intel_iommu=off",
2495 "module.sig_enforce=0",
2496 "init_on_alloc=0",
2497 "init_on_free=0",
2498 "randomize_kstack_offset=off",
2499 "page_alloc.shuffle=0",
2500 ];
2501 const PROTECTIVE_PREFIXES: &[&str] = &[
2502 "lockdown=",
2503 "module.sig_enforce=",
2504 "security=",
2505 "selinux=",
2506 "enforcing=",
2507 "apparmor=",
2508 "pti=",
2509 "mitigations=",
2510 "init_on_alloc=",
2511 "init_on_free=",
2512 "page_alloc.shuffle=",
2513 "randomize_kstack_offset=",
2514 "slub_debug=",
2515 "page_poison=",
2516 "vsyscall=",
2517 "debugfs=",
2518 "iommu=",
2519 "intel_iommu=",
2520 "amd_iommu=",
2521 ];
2522 const PROTECTIVE_EXACT: &[&str] = &["slab_nomerge", "nosmt", "kaslr"];
2523
2524 let lower = arg.to_lowercase();
2525 if WEAKENING.contains(&lower.as_str()) {
2526 return Ok(());
2527 }
2528 if PROTECTIVE_PREFIXES.iter().any(|p| lower.starts_with(p))
2531 || PROTECTIVE_EXACT.contains(&lower.as_str())
2532 {
2533 return Err(ExecutorError::InvalidParam(param));
2534 }
2535 Ok(())
2539}
2540
2541fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2549 let normalized = s.trim().to_ascii_uppercase();
2550 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2551 "TERM" => Ok("TERM"),
2552 "KILL" => Ok("KILL"),
2553 "HUP" => Ok("HUP"),
2554 "INT" => Ok("INT"),
2555 _ => Err(ExecutorError::InvalidParam("signal")),
2556 }
2557}
2558
2559pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2569 match action_name {
2570 "UpdateSystem"
2571 | "InstallPackages"
2572 | "RemovePackages"
2573 | "RebaseSystem"
2574 | "SetKernelArguments"
2575 | "AddLayeredPackage"
2576 | "RemoveLayeredPackage"
2577 | "ReplaceLayeredPackage"
2578 | "ResetLayeredPackageOverride"
2579 | "RemoveBasePackage" => Some(ActionSpec {
2580 action_name: "RollbackDeployment",
2581 mechanism: ActionMechanism::Command {
2582 program: "rpm-ostree",
2583 args: vec!["rollback".to_string()],
2584 },
2585 risk_level: sysknife_types::RiskLevel::High,
2586 reboot_required: true,
2587 rollback_available: false,
2588 }),
2589 _ => None,
2590 }
2591}
2592
2593#[cfg(test)]
2594mod tests {
2595 use super::*;
2596 use serde_json::json;
2597 use sysknife_types::RiskLevel;
2598 use tempfile::tempdir;
2599
2600 #[test]
2603 fn build_spec_rejects_unknown_keys_across_catalogue() {
2604 for name in sysknife_types::KNOWN_ACTION_NAMES {
2605 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2606 continue;
2607 }
2608 assert!(
2609 matches!(
2610 build_action_spec(name, &json!({"__unknown_param": 1})),
2611 Err(ExecutorError::InvalidParam(_))
2612 ),
2613 "{name} must reject unknown keys before reading required params"
2614 );
2615 }
2616 }
2617
2618 #[test]
2619 fn build_spec_rejects_non_object_params_across_catalogue() {
2620 for name in sysknife_types::KNOWN_ACTION_NAMES {
2621 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2622 continue;
2623 }
2624 for params in [json!(null), json!([]), json!(42), json!(true), json!("{}")] {
2625 assert!(
2626 matches!(
2627 build_action_spec(name, ¶ms),
2628 Err(ExecutorError::InvalidParam(_))
2629 ),
2630 "{name} must reject non-object params: {params}"
2631 );
2632 }
2633 }
2634 }
2635
2636 #[test]
2637 fn build_spec_rejects_cross_distro_kernel_argument_key() {
2638 assert!(matches!(
2639 build_action_spec("SetKernelArguments", &json!({"append": ["quiet"]})),
2640 Err(ExecutorError::InvalidParam(_))
2641 ));
2642 assert!(matches!(
2643 build_action_spec("UfwEnable", &json!({"x": 1})),
2644 Err(ExecutorError::InvalidParam(_))
2645 ));
2646 }
2647
2648 #[test]
2649 fn build_spec_accepts_catalogue_params_and_username_aliases() {
2650 for name in sysknife_types::KNOWN_ACTION_NAMES {
2651 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2652 continue;
2653 }
2654 let params = match *name {
2657 "PinDeployment" | "UnpinDeployment" => json!({"index": 0}),
2658 "RebaseSystem" => json!({"target_ref": "fedora-stable"}),
2659 "SetKernelArguments" => json!({"add": ["quiet"], "remove": ["splash"]}),
2660 "ListFlatpakRemotes"
2661 | "ListInstalledFlatpaks"
2662 | "ListContainers"
2663 | "ListToolboxes"
2664 | "DeleteUser"
2665 | "LockUserAccount"
2666 | "UnlockUserAccount"
2667 | "GetAuthorizedKeys"
2668 | "UbuntuListFlatpaks" => json!({"username": "alice"}),
2669 "InstallFlatpak" | "UbuntuInstallFlatpak" => {
2670 json!({"username": "alice", "app_id": "org.mozilla.firefox", "remote": "flathub"})
2671 }
2672 "RemoveFlatpak"
2673 | "GetFlatpakAppInfo"
2674 | "UbuntuRemoveFlatpak"
2675 | "UpdateFlatpak"
2676 | "UbuntuUpdateFlatpak" => {
2677 json!({"username": "alice", "app_id": "org.mozilla.firefox"})
2678 }
2679 "SearchFlatpakApps" | "AptSearch" => json!({"term": "vim"}),
2680 "AddFlatpakRemote" => {
2681 json!({"username": "alice", "remote": "flathub", "url": "https://flathub.org/repo/flathub.flatpakrepo"})
2682 }
2683 "RemoveFlatpakRemote" => json!({"username": "alice", "remote": "flathub"}),
2684 "CreateContainer" => {
2685 json!({"username": "alice", "name": "test", "image": "alpine"})
2686 }
2687 "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
2688 | "RemoveToolbox" => json!({"username": "alice", "name": "test"}),
2689 "InstallPackages" | "RemovePackages" => json!({"packages": ["vim"]}),
2690 "AddLayeredPackage"
2691 | "RemoveLayeredPackage"
2692 | "RemoveBasePackage"
2693 | "AptInstall"
2694 | "AptRemove"
2695 | "AptPurge"
2696 | "AptHold"
2697 | "AptUnhold"
2698 | "AptShow"
2699 | "GetAptPins" => json!({"package": "vim"}),
2700 "ReplaceLayeredPackage" => json!({"old": "vim", "new": "nano"}),
2701 "AddPackageRepository" => {
2702 json!({"repo_id": "test", "repo_url": "https://example.com/repo"})
2703 }
2704 "RemovePackageRepository"
2705 | "EnablePackageRepository"
2706 | "DisablePackageRepository" => json!({"repo_id": "test"}),
2707 "StartService"
2708 | "StopService"
2709 | "RestartService"
2710 | "MaskService"
2711 | "UnmaskService"
2712 | "GetServiceLogs"
2713 | "GetServiceStatus"
2714 | "ReloadService"
2715 | "GetServiceResourceLimits" => json!({"unit": "nginx.service"}),
2716 "SetServiceEnabled" => json!({"unit": "nginx.service", "enabled": true}),
2717 "CreateScheduledJob" => {
2718 json!({"name": "test", "command": "/usr/bin/true", "schedule": "daily"})
2719 }
2720 "SetServiceResourceLimits" => {
2721 json!({"unit": "nginx.service", "memory_max": "1G", "memory_high": "512M", "cpu_quota": "50%", "tasks_max": "100"})
2722 }
2723 "CreateToolbox" => {
2724 json!({"username": "alice", "name": "test", "image": "fedora-toolbox", "release": "41"})
2725 }
2726 "SetHostname" => json!({"hostname": "testhost"}),
2727 "SetTimezone" => json!({"timezone": "Europe/London"}),
2728 "SetLocale" => json!({"locale": "en_US.UTF-8"}),
2729 "SetNtp" | "ConfigureUnattendedUpgrades" => json!({"enabled": true}),
2730 "SignalProcess" => json!({"pid": 42, "signal": "TERM"}),
2731 "GetJournalLog" => {
2732 json!({"unit": "nginx.service", "priority": "warning", "since": "yesterday", "until": "now", "grep": "error", "lines": 10, "boot": true, "kernel": false})
2733 }
2734 "VacuumJournal" => json!({"size_mb": 10}),
2735 "ExtendLogicalVolume" => json!({"vg": "vg0", "lv": "data", "size": "+1G"}),
2736 "CreateLogicalVolume" => json!({"vg": "vg0", "name": "data", "size": "1G"}),
2737 "CreateLvSnapshot" => {
2738 json!({"vg": "vg0", "origin": "data", "snapshot": "backup", "size": "1G"})
2739 }
2740 "GetSysctl" => json!({"key": "net.ipv4.ip_forward"}),
2741 "SetSysctl" => json!({"key": "net.ipv4.ip_forward", "value": "1"}),
2742 "AddMount" => {
2743 json!({"device": "/dev/sdb1", "mountpoint": "/mnt/data", "fstype": "ext4", "options": "defaults"})
2744 }
2745 "RemoveMount" => json!({"mountpoint": "/mnt/data"}),
2746 "AddSwap" => json!({"file": "/swapfile", "size_mb": 1024}),
2747 "RemoveSwap" => json!({"file": "/swapfile"}),
2748 "GrantSudoAccess" => {
2749 json!({"name": "test", "user": "alice", "commands": "/usr/bin/uptime", "runas": "root", "nopasswd": true})
2750 }
2751 "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" => {
2752 json!({"name": "test"})
2753 }
2754 "SetAptPin" => {
2755 json!({"name": "test", "package": "vim", "pin": "version 1.*", "priority": 500})
2756 }
2757 "GetLogrotateStatus" => json!({"config": "/var/log/app.log"}),
2758 "ConfigureLogRotation" => {
2759 json!({"name": "test", "path": "/var/log/app.log", "frequency": "daily", "rotate": 7, "compress": true})
2760 }
2761 "ConfigureRemoteSyslog" => {
2762 json!({"host": "logs.example.com", "port": 514, "protocol": "tcp"})
2763 }
2764 "GetPasswordAging" => json!({"user": "alice"}),
2765 "SetPasswordAging" => {
2766 json!({"user": "alice", "max_days": 90, "min_days": 1, "warn_days": 7})
2767 }
2768 "SetPasswordPolicy" => {
2769 json!({"minlen": 12, "dcredit": -1, "ucredit": -1, "lcredit": -1, "ocredit": -1})
2770 }
2771 "SetAccountLockout" => json!({"deny": 5, "unlock_time": 600, "fail_interval": 900}),
2772 "ConfigureWifi" => json!({"ssid": "testwifi", "password": "examplepassword"}),
2773 "SetDnsServers" | "ResolvectlSetDns" => {
2774 json!({"interface": "eth0", "servers": ["1.1.1.1"]})
2775 }
2776 "ConfigureFirewall" => json!({"zone": "public", "service": "ssh", "enabled": true}),
2777 "CreateUser" => {
2778 json!({"username": "alice", "shell": "/bin/bash", "home": "/home/alice"})
2779 }
2780 "AddUserToGroup" | "RemoveUserFromGroup" => {
2781 json!({"username": "alice", "group": "developers"})
2782 }
2783 "CreateGroup" => json!({"group": "developers", "system": true}),
2784 "DeleteGroup" => json!({"group": "developers"}),
2785 "AddAuthorizedKey" | "RemoveAuthorizedKey" => {
2786 json!({"username": "alice", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey alice"})
2787 }
2788 "SetSshdOption" => json!({"option": "PasswordAuthentication", "value": "no"}),
2789 "AddPpa" | "RemovePpa" => json!({"name": "deadsnakes/ppa"}),
2790 "SnapInstall" => json!({"name": "test", "channel": "stable", "auto_update": true}),
2791 "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo"
2792 | "SnapRevert" | "SnapClassicInstall" | "DistroboxRemove" => {
2793 json!({"name": "test"})
2794 }
2795 "GrubSetKargs" => json!({"append": ["quiet"], "delete": ["splash"]}),
2796 "UfwAllow" | "UfwDeny" => json!({"port_or_service": "22/tcp"}),
2797 "UfwStatus" => json!({"numbered": true}),
2798 "DistroboxCreate" => json!({"name": "test", "image": "ubuntu"}),
2799 "NetplanSet" => json!({"key": "ethernets.eth0.dhcp4", "value": "true"}),
2800 "UfwDeleteRule" => json!({"rule_number": 1}),
2801 "UfwLimit" => json!({"target": "ssh"}),
2802 "ProAttach" => json!({"token": "exampletoken"}),
2803 "EnableProService" | "DisableProService" => json!({"service": "esm-apps"}),
2804 "AppArmorEnforce" | "AppArmorComplain" => {
2805 json!({"profile_path": "/etc/apparmor.d/usr.bin.test"})
2806 }
2807 "Fail2banStatus" => json!({"jail": "sshd"}),
2808 "Fail2banBanIp" | "Fail2banUnbanIp" => json!({"jail": "sshd", "ip": "192.0.2.1"}),
2809 "ConfigureFail2banJail" => {
2810 json!({"name": "sshd", "enabled": true, "maxretry": 5, "bantime": 600, "findtime": 900})
2811 }
2812 "AddAuditRule" => json!({"path": "/etc/passwd", "perms": "wa", "key": "identity"}),
2813 "RemoveAuditRule" => json!({"key": "identity"}),
2814 "ObtainCertificate" => {
2815 json!({"domains": ["example.com"], "domain": "example.com", "email": "admin@example.com", "challenge": "standalone"})
2816 }
2817 _ => json!({}),
2818 };
2819 build_action_spec(name, ¶ms)
2820 .unwrap_or_else(|err| panic!("valid parameters rejected for {name}: {err}"));
2821 if params.get("username").is_some() {
2822 let mut aliased = params;
2823 let username = aliased.as_object_mut().unwrap().remove("username").unwrap();
2824 aliased["user"] = username;
2825 build_action_spec(name, &aliased)
2826 .unwrap_or_else(|err| panic!("user alias rejected for {name}: {err}"));
2827 }
2828 }
2829 for params in [json!({"retain_days": 7}), json!({"size_mb": 10})] {
2830 build_action_spec("VacuumJournal", ¶ms).unwrap();
2831 }
2832 build_action_spec(
2833 "ObtainCertificate",
2834 &json!({"domain": "example.com", "email": "admin@example.com"}),
2835 )
2836 .unwrap();
2837 build_action_spec("SignalProcess", &json!({"pid": "42"})).unwrap();
2838 build_action_spec("SetKernelArguments", &json!({"add": null, "remove": null})).unwrap();
2839 }
2840
2841 #[test]
2842 fn build_spec_no_params_for_get_system_state() {
2843 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2844 assert_eq!(spec.action_name, "GetSystemState");
2845 assert_eq!(spec.risk_level, RiskLevel::Low);
2846 }
2847
2848 #[test]
2849 fn build_spec_get_datetime_is_low_risk() {
2850 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2851 assert_eq!(spec.action_name, "GetDateTime");
2852 assert_eq!(spec.risk_level, RiskLevel::Low);
2853 assert!(!spec.reboot_required);
2854 }
2855
2856 #[test]
2857 fn build_spec_unknown_action_returns_error() {
2858 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2859 assert!(
2860 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2861 "expected UnknownAction, got: {err}"
2862 );
2863 }
2864
2865 #[test]
2866 fn build_spec_missing_param_for_install_flatpak() {
2867 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2869 assert!(
2870 matches!(err, ExecutorError::MissingParam("username")),
2871 "expected MissingParam(username), got: {err}"
2872 );
2873 }
2874
2875 #[test]
2879 fn build_spec_flatpak_accepts_user_alias() {
2880 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2881 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2882 }
2883
2884 #[test]
2886 fn build_spec_resolve_username_prefers_explicit_username() {
2887 let spec = build_action_spec(
2888 "ListInstalledFlatpaks",
2889 &json!({ "username": "alice", "user": "bob" }),
2890 )
2891 .unwrap();
2892 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2894 }
2895
2896 #[test]
2899 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2900 let spec = build_action_spec(
2901 "InstallFlatpak",
2902 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2903 )
2904 .unwrap();
2905 assert_eq!(
2906 spec.mechanism,
2907 ActionMechanism::Command {
2908 program: "sudo",
2909 args: vec![
2910 "/usr/lib/sysknife/action-steps".to_string(),
2911 "flatpak".to_string(),
2912 "alice".to_string(),
2913 "install".to_string(),
2914 "--user".to_string(),
2915 "-y".to_string(),
2916 "flathub".to_string(),
2917 "org.mozilla.firefox".to_string(),
2918 ],
2919 }
2920 );
2921 }
2922
2923 #[test]
2924 fn build_spec_install_flatpak_injects_app_and_remote() {
2925 let spec = build_action_spec(
2926 "InstallFlatpak",
2927 &json!({
2928 "username": "alice",
2929 "app_id": "org.mozilla.firefox",
2930 "remote": "flathub"
2931 }),
2932 )
2933 .unwrap();
2934 assert_eq!(spec.action_name, "InstallFlatpak");
2935 assert_eq!(
2936 spec.mechanism,
2937 ActionMechanism::Command {
2938 program: "sudo",
2939 args: vec![
2940 "/usr/lib/sysknife/action-steps".to_string(),
2941 "flatpak".to_string(),
2942 "alice".to_string(),
2943 "install".to_string(),
2944 "--user".to_string(),
2945 "-y".to_string(),
2946 "flathub".to_string(),
2947 "org.mozilla.firefox".to_string(),
2948 ],
2949 }
2950 );
2951 }
2952
2953 #[test]
2954 fn build_spec_pin_deployment_injects_index() {
2955 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2956 assert_eq!(
2957 spec.mechanism,
2958 ActionMechanism::Command {
2959 program: "sudo",
2960 args: vec![
2961 "ostree".to_string(),
2962 "admin".to_string(),
2963 "pin".to_string(),
2964 "1".to_string()
2965 ],
2966 }
2967 );
2968 }
2969
2970 #[test]
2971 fn build_spec_unpin_deployment_includes_unpin_flag() {
2972 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2973 assert_eq!(
2974 spec.mechanism,
2975 ActionMechanism::Command {
2976 program: "sudo",
2977 args: vec![
2978 "ostree".to_string(),
2979 "admin".to_string(),
2980 "pin".to_string(),
2981 "--unpin".to_string(),
2982 "2".to_string(),
2983 ],
2984 }
2985 );
2986 }
2987
2988 #[test]
2989 fn require_u32_rejects_overflow() {
2990 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2991 assert!(
2992 matches!(err, ExecutorError::InvalidParam("index")),
2993 "expected InvalidParam(index), got: {err}"
2994 );
2995 }
2996
2997 #[test]
2998 fn build_spec_rebase_system_injects_target_ref() {
2999 let spec = build_action_spec(
3000 "RebaseSystem",
3001 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
3002 )
3003 .unwrap();
3004 assert_eq!(
3005 spec.mechanism,
3006 ActionMechanism::Command {
3007 program: "sudo",
3008 args: vec![
3009 "rpm-ostree".to_string(),
3010 "rebase".to_string(),
3011 "fedora/41/x86_64/silverblue".to_string(),
3012 ],
3013 }
3014 );
3015 }
3016
3017 #[test]
3018 fn build_spec_set_kernel_arguments_appends_and_deletes() {
3019 let spec = build_action_spec(
3020 "SetKernelArguments",
3021 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
3022 )
3023 .unwrap();
3024 assert_eq!(
3025 spec.mechanism,
3026 ActionMechanism::Command {
3027 program: "sudo",
3028 args: vec![
3029 "rpm-ostree".to_string(),
3030 "kargs".to_string(),
3031 "--append=nomodeset".to_string(),
3032 "--delete=quiet".to_string(),
3033 ],
3034 }
3035 );
3036 }
3037
3038 #[test]
3039 fn build_spec_set_kernel_arguments_with_empty_arrays() {
3040 let spec =
3041 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
3042 assert_eq!(
3043 spec.mechanism,
3044 ActionMechanism::Command {
3045 program: "sudo",
3046 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
3047 }
3048 );
3049 }
3050
3051 #[test]
3052 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
3053 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
3054 assert_eq!(
3055 spec.mechanism,
3056 ActionMechanism::Command {
3057 program: "sudo",
3058 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
3059 }
3060 );
3061 }
3062
3063 #[test]
3066 fn delete_user_rejects_critical_account() {
3067 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
3068 assert!(
3069 matches!(err, ExecutorError::InvalidParam("username")),
3070 "expected InvalidParam(username), got: {err}"
3071 );
3072 }
3073
3074 #[test]
3075 fn delete_group_rejects_critical_group() {
3076 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
3077 assert!(
3078 matches!(err, ExecutorError::InvalidParam("group")),
3079 "expected InvalidParam(group), got: {err}"
3080 );
3081 }
3082
3083 #[test]
3084 fn lock_user_account_rejects_critical_account() {
3085 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
3086 assert!(
3087 matches!(err, ExecutorError::InvalidParam("username")),
3088 "expected InvalidParam(username), got: {err}"
3089 );
3090 }
3091
3092 #[test]
3093 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
3094 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
3095 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
3096 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
3097 }
3098
3099 #[test]
3102 fn build_spec_add_package_repository_rejects_path_traversal() {
3103 let err = build_action_spec(
3104 "AddPackageRepository",
3105 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
3106 )
3107 .unwrap_err();
3108 assert!(
3109 matches!(err, ExecutorError::InvalidParam("repo_id")),
3110 "expected InvalidParam(repo_id), got: {err}"
3111 );
3112 }
3113
3114 #[test]
3115 fn build_spec_add_package_repository_rejects_newline_in_url() {
3116 let err = build_action_spec(
3117 "AddPackageRepository",
3118 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
3119 )
3120 .unwrap_err();
3121 assert!(
3122 matches!(err, ExecutorError::InvalidParam("repo_url")),
3123 "expected InvalidParam(repo_url), got: {err}"
3124 );
3125 }
3126
3127 #[test]
3128 fn build_spec_add_package_repository_accepts_valid_repo_id() {
3129 let spec = build_action_spec(
3130 "AddPackageRepository",
3131 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
3132 )
3133 .unwrap();
3134 assert_eq!(spec.action_name, "AddPackageRepository");
3135 }
3136
3137 #[test]
3138 fn build_spec_remove_package_repository_rejects_path_traversal() {
3139 let err = build_action_spec(
3140 "RemovePackageRepository",
3141 &json!({ "repo_id": "../../etc/passwd" }),
3142 )
3143 .unwrap_err();
3144 assert!(
3145 matches!(err, ExecutorError::InvalidParam("repo_id")),
3146 "expected InvalidParam(repo_id), got: {err}"
3147 );
3148 }
3149
3150 #[tokio::test]
3151 async fn execute_spec_command_captures_stdout() {
3152 let spec = ActionSpec {
3153 action_name: "GetSystemState",
3154 mechanism: ActionMechanism::Command {
3155 program: "echo",
3156 args: vec!["hello".to_string()],
3157 },
3158 risk_level: RiskLevel::Low,
3159 reboot_required: false,
3160 rollback_available: false,
3161 };
3162 let out = execute_spec(&spec).await.unwrap();
3163 assert_eq!(out.stdout.trim(), "hello");
3164 assert_eq!(out.exit_code, 0);
3165 }
3166
3167 #[test]
3175 fn action_not_stopped_names_the_group_and_the_timeout() {
3176 let err = ExecutorError::ActionNotStopped {
3177 program: "rpm-ostree".to_string(),
3178 pgid: 4242,
3179 timeout_secs: 7200,
3180 };
3181 let msg = err.to_string();
3182 assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
3183 assert!(msg.contains("7200"), "must name the timeout: {msg}");
3184 assert!(
3185 msg.contains("could not be confirmed stopped"),
3186 "must say what happened: {msg}"
3187 );
3188 assert!(
3189 msg.contains("rollback was skipped"),
3190 "must say the rollback was skipped: {msg}"
3191 );
3192 }
3193
3194 #[tokio::test]
3199 async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
3200 let mut child = tokio::process::Command::new("sh")
3201 .args(["-c", "sleep 3600 & wait"])
3202 .process_group(0)
3203 .stdin(Stdio::null())
3204 .stdout(Stdio::null())
3205 .stderr(Stdio::null())
3206 .spawn()
3207 .expect("spawn");
3208 SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
3209 let started = tokio::time::Instant::now();
3210 let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
3211 let elapsed = started.elapsed();
3212
3213 assert!(
3214 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
3215 "a killable group must report a plain stopped-timeout, got: {err:?}"
3216 );
3217 assert!(
3218 elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
3219 "must return promptly, took {elapsed:?}"
3220 );
3221 assert_eq!(
3225 SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
3226 0,
3227 "a killable group must not escalate to the privileged reaper"
3228 );
3229 }
3230
3231 #[tokio::test]
3239 async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
3240 let dir = tempfile::tempdir().expect("tempdir");
3241 let pgid_file = dir.path().join("pgid");
3242 let script = format!(
3246 "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
3247 pgid_file.display()
3248 );
3249 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3250 let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
3251 .await
3252 .expect_err("non-UTF-8 stdout must fail the action");
3253 assert!(
3254 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
3255 "the read failure itself must be surfaced, got: {err:?}"
3256 );
3257
3258 let pgid: i32 = std::fs::read_to_string(&pgid_file)
3259 .expect("the shell must have recorded its pgid")
3260 .trim()
3261 .parse()
3262 .expect("pgid");
3263 assert!(
3264 probe_group(pgid).is_gone(),
3265 "process group {pgid} survived the read failure; \
3266 the privileged child was orphaned"
3267 );
3268 }
3269
3270 #[test]
3271 fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
3272 assert_eq!(
3274 reap_command(
3275 99,
3276 "sudo",
3277 &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
3278 ),
3279 vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
3280 );
3281 assert_eq!(
3283 reap_command(
3284 99,
3285 "sudo",
3286 &[
3287 "env".to_string(),
3288 "apt-get".to_string(),
3289 "install".to_string()
3290 ]
3291 ),
3292 root_group_kill_argv("TERM", 99)
3293 );
3294 }
3295
3296 #[test]
3297 fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
3298 assert_eq!(
3301 root_group_kill_argv("KILL", 4242),
3302 vec!["-n", "/usr/bin/kill", "-s", "KILL", "--", "-4242"]
3303 );
3304 assert_eq!(
3305 root_group_kill_argv("TERM", 7)[3..].to_vec(),
3306 vec!["TERM", "--", "-7"]
3307 );
3308 }
3309
3310 #[test]
3311 fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
3312 let sudo = |a: &[&str]| {
3313 is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
3314 };
3315 assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
3317 assert!(sudo(&[
3318 "rpm-ostree",
3319 "rebase",
3320 "fedora:fedora/40/x86_64/silverblue"
3321 ]));
3322 assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
3323 assert!(!sudo(&[
3326 "env",
3327 "DEBIAN_FRONTEND=noninteractive",
3328 "apt-get",
3329 "install",
3330 "vim"
3331 ]));
3332 assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
3333 assert!(!is_rpm_ostree_action("sudo", &[]));
3334 assert!(is_rpm_ostree_action(
3336 "/usr/bin/rpm-ostree",
3337 &["deploy".to_string()]
3338 ));
3339 assert!(!is_rpm_ostree_action(
3342 "sudo",
3343 &["explain-rpm-ostree-usage".to_string()]
3344 ));
3345 }
3346
3347 #[tokio::test]
3350 async fn real_executor_streams_each_stdout_line() {
3351 let spec = ActionSpec {
3352 action_name: "GetSystemState",
3353 mechanism: ActionMechanism::Command {
3354 program: "printf",
3355 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
3356 },
3357 risk_level: RiskLevel::Low,
3358 reboot_required: false,
3359 rollback_available: false,
3360 };
3361 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3362 let out = RealActionExecutor
3363 .execute_with_progress(&spec, tx)
3364 .await
3365 .unwrap();
3366
3367 let mut lines = Vec::new();
3368 while let Ok(line) = rx.try_recv() {
3369 lines.push(line);
3370 }
3371 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
3372 assert_eq!(out.exit_code, 0);
3373 assert!(out.stdout.contains("line-two"));
3374 }
3375
3376 #[tokio::test]
3377 async fn execute_spec_file_write_creates_file() {
3378 let dir = tempdir().unwrap();
3379 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
3380 let spec = ActionSpec {
3381 action_name: "AddPackageRepository",
3382 mechanism: ActionMechanism::FileWrite {
3383 path: path.clone(),
3384 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
3385 },
3386 risk_level: RiskLevel::Medium,
3387 reboot_required: false,
3388 rollback_available: true,
3389 };
3390 let out = execute_spec(&spec).await.unwrap();
3391 assert_eq!(out.exit_code, 0);
3392 assert_eq!(
3393 std::fs::read_to_string(&path).unwrap(),
3394 "[repo]\nbaseurl=https://example.test\n"
3395 );
3396 }
3397
3398 #[tokio::test]
3399 async fn execute_spec_file_patch_replaces_first_occurrence() {
3400 let dir = tempdir().unwrap();
3401 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3402 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
3403 let spec = ActionSpec {
3404 action_name: "EnablePackageRepository",
3405 mechanism: ActionMechanism::FilePatch {
3406 path: path.clone(),
3407 search: "enabled=0".to_string(),
3408 replace: "enabled=1".to_string(),
3409 },
3410 risk_level: RiskLevel::Medium,
3411 reboot_required: false,
3412 rollback_available: true,
3413 };
3414 execute_spec(&spec).await.unwrap();
3415 assert_eq!(
3416 std::fs::read_to_string(&path).unwrap(),
3417 "[myrepo]\nenabled=1\n"
3418 );
3419 }
3420
3421 #[tokio::test]
3422 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
3423 let dir = tempdir().unwrap();
3424 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3425 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
3426 let spec = ActionSpec {
3427 action_name: "EnablePackageRepository",
3428 mechanism: ActionMechanism::FilePatch {
3429 path: path.clone(),
3430 search: "enabled=0".to_string(),
3431 replace: "enabled=1".to_string(),
3432 },
3433 risk_level: RiskLevel::Medium,
3434 reboot_required: false,
3435 rollback_available: true,
3436 };
3437 let out = execute_spec(&spec).await.unwrap();
3438 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
3439 assert!(
3440 out.stderr.contains("search string not found in file"),
3441 "stderr should explain the failure: {}",
3442 out.stderr
3443 );
3444 assert_eq!(
3446 std::fs::read_to_string(&path).unwrap(),
3447 "[myrepo]\nenabled=1\n"
3448 );
3449 }
3450
3451 #[tokio::test]
3452 async fn execute_spec_file_patch_allows_empty_search_string() {
3453 let dir = tempdir().unwrap();
3456 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
3457 std::fs::write(&path, "hello").unwrap();
3458 let spec = ActionSpec {
3459 action_name: "Test",
3460 mechanism: ActionMechanism::FilePatch {
3461 path: path.clone(),
3462 search: String::new(),
3463 replace: "prefix-".to_string(),
3464 },
3465 risk_level: RiskLevel::Low,
3466 reboot_required: false,
3467 rollback_available: false,
3468 };
3469 let out = execute_spec(&spec).await.unwrap();
3470 assert_eq!(out.exit_code, 0);
3471 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3472 }
3473
3474 #[tokio::test]
3475 async fn execute_spec_file_delete_removes_file() {
3476 let dir = tempdir().unwrap();
3477 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3478 std::fs::write(&path, "[myrepo]\n").unwrap();
3479 let spec = ActionSpec {
3480 action_name: "RemovePackageRepository",
3481 mechanism: ActionMechanism::FileDelete { path: path.clone() },
3482 risk_level: RiskLevel::Medium,
3483 reboot_required: false,
3484 rollback_available: true,
3485 };
3486 execute_spec(&spec).await.unwrap();
3487 assert!(!std::path::Path::new(&path).exists());
3488 }
3489
3490 #[tokio::test]
3491 async fn execute_spec_file_scan_lists_directory_entries() {
3492 let dir = tempdir().unwrap();
3493 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3494 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3495 let spec = ActionSpec {
3496 action_name: "ListPackageRepositories",
3497 mechanism: ActionMechanism::FileScan {
3498 path: dir.path().to_string_lossy().into_owned(),
3499 },
3500 risk_level: RiskLevel::Low,
3501 reboot_required: false,
3502 rollback_available: false,
3503 };
3504 let out = execute_spec(&spec).await.unwrap();
3505 assert!(
3506 out.stdout.contains("a.repo"),
3507 "expected a.repo in: {}",
3508 out.stdout
3509 );
3510 assert!(
3511 out.stdout.contains("b.repo"),
3512 "expected b.repo in: {}",
3513 out.stdout
3514 );
3515 assert_eq!(out.exit_code, 0);
3516 }
3517
3518 #[test]
3521 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3522 let spec = rollback_spec_for("UpdateSystem").unwrap();
3523 assert_eq!(spec.action_name, "RollbackDeployment");
3524 assert!(
3525 matches!(
3526 &spec.mechanism,
3527 ActionMechanism::Command { program: "rpm-ostree", args }
3528 if args == &["rollback".to_string()]
3529 ),
3530 "expected rpm-ostree rollback, got: {:?}",
3531 spec.mechanism
3532 );
3533 assert!(!spec.rollback_available, "rollback spec must not recurse");
3534 }
3535
3536 #[test]
3537 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3538 assert!(rollback_spec_for("InstallPackages").is_some());
3539 }
3540
3541 #[test]
3542 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3543 assert!(rollback_spec_for("RemovePackages").is_some());
3544 }
3545
3546 #[test]
3547 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3548 assert!(rollback_spec_for("RebaseSystem").is_some());
3549 }
3550
3551 #[test]
3552 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3553 assert!(rollback_spec_for("SetKernelArguments").is_some());
3554 }
3555
3556 #[test]
3557 fn rollback_spec_for_read_only_action_returns_none() {
3558 assert!(rollback_spec_for("GetSystemState").is_none());
3559 assert!(rollback_spec_for("ListUsers").is_none());
3560 assert!(rollback_spec_for("GetFirewallState").is_none());
3561 }
3562
3563 #[test]
3564 fn rollback_spec_for_non_rollbackable_actions_return_none() {
3565 assert!(rollback_spec_for("AddUserToGroup").is_none());
3566 assert!(rollback_spec_for("DeleteUser").is_none());
3567 assert!(rollback_spec_for("CleanupDeployments").is_none());
3568 assert!(rollback_spec_for("RollbackDeployment").is_none());
3570 }
3571
3572 #[test]
3575 fn public_key_accepts_valid_ed25519() {
3576 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3577 assert!(validated_public_key(key).is_ok());
3578 }
3579
3580 #[test]
3581 fn public_key_accepts_valid_rsa() {
3582 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3583 assert!(validated_public_key(key).is_ok());
3584 }
3585
3586 #[test]
3587 fn public_key_rejects_empty() {
3588 assert!(matches!(
3589 validated_public_key(""),
3590 Err(ExecutorError::InvalidParam("public_key"))
3591 ));
3592 }
3593
3594 #[test]
3595 fn public_key_rejects_unknown_prefix() {
3596 assert!(matches!(
3597 validated_public_key("sk-rsa AAAA... user@host"),
3598 Err(ExecutorError::InvalidParam("public_key"))
3599 ));
3600 assert!(matches!(
3601 validated_public_key("AAAAB3Nz... user@host"),
3602 Err(ExecutorError::InvalidParam("public_key"))
3603 ));
3604 }
3605
3606 #[test]
3607 fn public_key_rejects_single_quote() {
3608 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3609 assert!(matches!(
3610 validated_public_key(key),
3611 Err(ExecutorError::InvalidParam("public_key"))
3612 ));
3613 }
3614
3615 #[test]
3623 fn package_actions_reject_a_local_file_target() {
3624 let reject: &[(&str, serde_json::Value)] = &[
3625 ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3626 ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3627 ("AptPurge", json!({"package": "./evil.deb"})),
3628 ("AptHold", json!({"package": "/tmp/evil.deb"})),
3629 ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3630 ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3631 ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3632 ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3633 (
3634 "ReplaceLayeredPackage",
3635 json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3636 ),
3637 (
3638 "ReplaceLayeredPackage",
3639 json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3640 ),
3641 ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3642 ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3643 ];
3644 for (action, params) in reject {
3645 assert!(
3646 matches!(
3647 build_action_spec(action, params),
3648 Err(ExecutorError::InvalidParam(_))
3649 ),
3650 "{action} must refuse a local-file package target: {params}"
3651 );
3652 }
3653 let accept: &[(&str, serde_json::Value)] = &[
3656 ("AptInstall", json!({"package": "nginx"})),
3657 ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3658 (
3659 "InstallPackages",
3660 json!({"packages": ["nginx", "python3-pip"]}),
3661 ),
3662 ];
3663 for (action, params) in accept {
3664 assert!(
3665 build_action_spec(action, params).is_ok(),
3666 "{action} must accept a real package spec: {params}"
3667 );
3668 }
3669 }
3670
3671 #[test]
3676 fn activating_actions_refuse_a_root_shell_unit() {
3677 for action in ["StartService", "RestartService", "UnmaskService"] {
3678 for unit in [
3679 "debug-shell.service",
3680 "emergency.target",
3681 "rescue.service",
3682 "runlevel1.target",
3683 ] {
3684 assert!(
3685 matches!(
3686 build_action_spec(action, &json!({"unit": unit})),
3687 Err(ExecutorError::InvalidParam("unit"))
3688 ),
3689 "{action} must not activate {unit}"
3690 );
3691 }
3692 assert!(
3693 build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3694 "{action} must still activate an ordinary unit"
3695 );
3696 }
3697 assert!(
3698 build_action_spec(
3699 "SetServiceEnabled",
3700 &json!({"unit": "debug-shell.service", "enabled": true})
3701 )
3702 .is_err(),
3703 "enabling a root-shell unit brings it up at boot and must be refused"
3704 );
3705 assert!(
3706 build_action_spec(
3707 "SetServiceEnabled",
3708 &json!({"unit": "debug-shell.service", "enabled": false})
3709 )
3710 .is_ok(),
3711 "disabling a root-shell unit is a mitigation and must stay reachable"
3712 );
3713 for action in ["StopService", "MaskService"] {
3716 assert!(
3717 build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3718 "{action} on debug-shell is a mitigation and must stay allowed"
3719 );
3720 }
3721 }
3722
3723 #[test]
3724 fn authorized_keys_actions_reject_a_traversal_username() {
3725 for action in [
3732 "GetAuthorizedKeys",
3733 "AddAuthorizedKey",
3734 "RemoveAuthorizedKey",
3735 ] {
3736 for bad in ["../../etc", "..", "root/../../etc"] {
3737 let mut params = json!({"username": bad});
3738 if action != "GetAuthorizedKeys" {
3739 params["public_key"] =
3740 json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h");
3741 }
3742 let err = build_action_spec(action, ¶ms).unwrap_err();
3743 assert!(
3744 matches!(err, ExecutorError::InvalidParam("username")),
3745 "{action} must reject username {bad:?}, got {err:?}"
3746 );
3747 }
3748 }
3749 }
3750
3751 #[test]
3752 fn ip_taking_actions_reject_a_malformed_address() {
3753 let cases: [(&str, serde_json::Value); 3] = [
3756 (
3757 "ResolvectlSetDns",
3758 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
3759 ),
3760 (
3761 "Fail2banBanIp",
3762 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
3763 ),
3764 (
3765 "Fail2banUnbanIp",
3766 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
3767 ),
3768 ];
3769 for (action, params) in cases {
3770 let err = build_action_spec(action, ¶ms).unwrap_err();
3771 assert!(
3772 matches!(err, ExecutorError::InvalidIpAddress { .. }),
3773 "{action} must reject a malformed IP, got {err:?}"
3774 );
3775 }
3776 }
3777
3778 #[test]
3779 fn public_key_rejects_pipe_metacharacter() {
3780 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
3784 assert!(matches!(
3785 validated_public_key(key),
3786 Err(ExecutorError::InvalidParam("public_key"))
3787 ));
3788 }
3789
3790 #[test]
3791 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
3792 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
3799 assert!(validated_public_key(key).is_ok());
3800 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
3803 }
3804
3805 #[test]
3806 fn public_key_rejects_shell_metacharacters() {
3807 for (metachar, desc) in [
3808 (';', "semicolon"),
3809 ('`', "backtick"),
3810 ('$', "dollar"),
3811 ('\\', "backslash"),
3812 ('&', "ampersand"),
3813 ] {
3814 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
3815 assert!(
3816 matches!(
3817 validated_public_key(&key),
3818 Err(ExecutorError::InvalidParam("public_key"))
3819 ),
3820 "{desc} should be rejected"
3821 );
3822 }
3823 }
3824
3825 #[test]
3826 fn public_key_rejects_newline() {
3827 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
3828 assert!(matches!(
3829 validated_public_key(key),
3830 Err(ExecutorError::InvalidParam("public_key"))
3831 ));
3832 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
3833 assert!(matches!(
3834 validated_public_key(key_cr),
3835 Err(ExecutorError::InvalidParam("public_key"))
3836 ));
3837 }
3838
3839 #[test]
3840 fn public_key_rejects_too_long() {
3841 let long_payload = "A".repeat(8192);
3843 let key = format!("ssh-ed25519 {long_payload} user@host");
3844 assert!(matches!(
3845 validated_public_key(&key),
3846 Err(ExecutorError::InvalidParam("public_key"))
3847 ));
3848 }
3849
3850 #[test]
3853 fn str_array_or_empty_rejects_non_string_element() {
3854 let params = json!({ "packages": ["vim", 42, "curl"] });
3855 assert!(matches!(
3856 str_array_or_empty(¶ms, "packages"),
3857 Err(ExecutorError::InvalidParam("packages"))
3858 ));
3859 }
3860
3861 #[test]
3862 fn str_array_or_empty_accepts_string_array() {
3863 let params = json!({ "packages": ["vim", "curl"] });
3864 assert_eq!(
3865 str_array_or_empty(¶ms, "packages").unwrap(),
3866 vec!["vim".to_string(), "curl".to_string()]
3867 );
3868 }
3869
3870 #[test]
3871 fn str_array_or_empty_returns_empty_when_key_absent() {
3872 let params = json!({});
3873 assert_eq!(
3874 str_array_or_empty(¶ms, "packages").unwrap(),
3875 Vec::<String>::new()
3876 );
3877 }
3878
3879 #[test]
3882 fn kernel_arg_allows_safe_args() {
3883 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3884 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3885 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3886 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3887 }
3888
3889 #[test]
3890 fn kernel_arg_blocks_init_override() {
3891 assert!(matches!(
3892 validated_safe_kernel_arg("init=/bin/sh", "add"),
3893 Err(ExecutorError::InvalidParam("add"))
3894 ));
3895 assert!(matches!(
3896 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3897 Err(ExecutorError::InvalidParam("add"))
3898 ));
3899 }
3900
3901 #[test]
3902 fn kernel_arg_blocks_selinux_disable() {
3903 assert!(matches!(
3904 validated_safe_kernel_arg("selinux=0", "add"),
3905 Err(ExecutorError::InvalidParam("add"))
3906 ));
3907 assert!(matches!(
3908 validated_safe_kernel_arg("enforcing=0", "add"),
3909 Err(ExecutorError::InvalidParam("add"))
3910 ));
3911 }
3912
3913 #[test]
3914 fn kernel_arg_blocks_security_override() {
3915 assert!(matches!(
3916 validated_safe_kernel_arg("security=none", "add"),
3917 Err(ExecutorError::InvalidParam("add"))
3918 ));
3919 }
3920
3921 #[test]
3922 fn kernel_arg_blocks_module_blacklist() {
3923 assert!(matches!(
3924 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3925 Err(ExecutorError::InvalidParam("add"))
3926 ));
3927 }
3928
3929 #[test]
3937 fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
3938 for arg in [
3941 "lockdown=confidentiality",
3942 "module.sig_enforce=1",
3943 "selinux=1",
3944 "enforcing=1",
3945 "apparmor=1",
3946 "security=selinux",
3947 "pti=on",
3948 "mitigations=auto,nosmt",
3949 "init_on_alloc=1",
3950 "init_on_free=1",
3951 "slab_nomerge",
3952 "page_alloc.shuffle=1",
3953 "randomize_kstack_offset=on",
3954 "slub_debug=FZP",
3955 "vsyscall=none",
3956 "debugfs=off",
3957 "iommu=force",
3958 "intel_iommu=on",
3959 "amd_iommu=force_isolation",
3960 ] {
3961 assert!(
3962 matches!(
3963 validated_safe_kernel_arg_removal(arg, "remove"),
3964 Err(ExecutorError::InvalidParam("remove"))
3965 ),
3966 "removing {arg} is a boot-security downgrade and must be refused"
3967 );
3968 }
3969 }
3970
3971 #[test]
3975 fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
3976 for arg in [
3977 "mitigations=off",
3978 "selinux=0",
3979 "enforcing=0",
3980 "apparmor=0",
3981 "pti=off",
3982 "lockdown=none",
3983 "debugfs=on",
3984 "init=/bin/sh",
3985 "single",
3986 ] {
3987 assert!(
3988 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3989 "{arg} weakens the host, so removing it must stay allowed"
3990 );
3991 }
3992 }
3993
3994 #[test]
3995 fn kernel_arg_removal_allows_ordinary_args() {
3996 for arg in [
3997 "quiet",
3998 "splash",
3999 "nomodeset",
4000 "console=ttyS0,115200",
4001 "rd.driver.blacklist=nouveau",
4002 ] {
4003 assert!(
4004 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
4005 "{arg} carries no boot-security meaning and must stay removable"
4006 );
4007 }
4008 }
4009
4010 #[test]
4011 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
4012 assert!(matches!(
4013 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
4014 Err(ExecutorError::InvalidParam("add"))
4015 ));
4016 assert!(matches!(
4017 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
4018 Err(ExecutorError::InvalidParam("add"))
4019 ));
4020 assert!(matches!(
4021 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
4022 Err(ExecutorError::InvalidParam("add"))
4023 ));
4024 }
4025
4026 #[test]
4027 fn kernel_arg_blocks_apparmor_disable() {
4028 assert!(matches!(
4029 validated_safe_kernel_arg("apparmor=0", "add"),
4030 Err(ExecutorError::InvalidParam("add"))
4031 ));
4032 }
4033
4034 #[test]
4035 fn kernel_arg_blocks_mitigations_off() {
4036 assert!(matches!(
4037 validated_safe_kernel_arg("mitigations=off", "add"),
4038 Err(ExecutorError::InvalidParam("add"))
4039 ));
4040 }
4041
4042 #[test]
4043 fn kernel_arg_blocks_lockdown_override() {
4044 assert!(matches!(
4045 validated_safe_kernel_arg("lockdown=none", "add"),
4046 Err(ExecutorError::InvalidParam("add"))
4047 ));
4048 }
4049
4050 #[test]
4051 fn kernel_arg_blocks_pti_off() {
4052 assert!(matches!(
4053 validated_safe_kernel_arg("pti=off", "add"),
4054 Err(ExecutorError::InvalidParam("add"))
4055 ));
4056 }
4057
4058 #[test]
4059 fn kernel_arg_blocks_smap_smep_disable() {
4060 assert!(matches!(
4061 validated_safe_kernel_arg("nosmap", "add"),
4062 Err(ExecutorError::InvalidParam("add"))
4063 ));
4064 assert!(matches!(
4065 validated_safe_kernel_arg("nosmep", "add"),
4066 Err(ExecutorError::InvalidParam("add"))
4067 ));
4068 }
4069
4070 #[test]
4071 fn kernel_arg_blocks_single_user_shortcuts() {
4072 assert!(matches!(
4074 validated_safe_kernel_arg("single", "add"),
4075 Err(ExecutorError::InvalidParam("add"))
4076 ));
4077 assert!(matches!(
4078 validated_safe_kernel_arg("s", "add"),
4079 Err(ExecutorError::InvalidParam("add"))
4080 ));
4081 assert!(matches!(
4082 validated_safe_kernel_arg("1", "add"),
4083 Err(ExecutorError::InvalidParam("add"))
4084 ));
4085 }
4086
4087 #[test]
4088 fn signal_process_guardrails() {
4089 for bad_pid in [0, 1] {
4091 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
4092 assert!(
4093 matches!(err, ExecutorError::InvalidParam("pid")),
4094 "pid {bad_pid} must be rejected, got {err:?}"
4095 );
4096 }
4097 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
4099 .unwrap_err();
4100 assert!(
4101 matches!(err, ExecutorError::InvalidParam("signal")),
4102 "got {err:?}"
4103 );
4104 assert!(matches!(
4106 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
4107 ExecutorError::MissingParam("pid")
4108 ));
4109 let spec = build_action_spec(
4111 "SignalProcess",
4112 &json!({ "pid": "4242", "signal": "sigkill" }),
4113 )
4114 .unwrap();
4115 assert_eq!(spec.action_name, "SignalProcess");
4116 assert_eq!(
4117 spec.mechanism,
4118 ActionMechanism::Command {
4119 program: "sudo",
4120 args: vec![
4121 "kill".to_string(),
4122 "-s".to_string(),
4123 "KILL".to_string(),
4124 "4242".to_string()
4125 ],
4126 }
4127 );
4128 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
4130 if let ActionMechanism::Command { args, .. } = spec.mechanism {
4131 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
4132 } else {
4133 panic!("expected Command mechanism");
4134 }
4135 }
4136
4137 #[test]
4138 fn create_scheduled_job_validates_name_command_schedule() {
4139 assert!(matches!(
4141 build_action_spec(
4142 "CreateScheduledJob",
4143 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
4144 )
4145 .unwrap_err(),
4146 ExecutorError::InvalidParam("command")
4147 ));
4148 assert!(matches!(
4150 build_action_spec(
4151 "CreateScheduledJob",
4152 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
4153 )
4154 .unwrap_err(),
4155 ExecutorError::InvalidParam("name")
4156 ));
4157 assert!(matches!(
4159 build_action_spec(
4160 "CreateScheduledJob",
4161 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
4162 )
4163 .unwrap_err(),
4164 ExecutorError::InvalidParam("schedule")
4165 ));
4166 let spec = build_action_spec(
4168 "CreateScheduledJob",
4169 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
4170 )
4171 .unwrap();
4172 assert_eq!(
4173 spec.mechanism,
4174 ActionMechanism::Command {
4175 program: "sudo",
4176 args: vec![
4177 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
4178 "--name".to_string(),
4179 "nightly-backup".to_string(),
4180 "--command".to_string(),
4181 "/usr/bin/backup --full".to_string(),
4182 "--schedule".to_string(),
4183 "*-*-* 02:00:00".to_string(),
4184 ],
4185 }
4186 );
4187 }
4188
4189 #[test]
4190 fn set_sshd_option_allowlist_guardrails() {
4191 assert!(matches!(
4193 build_action_spec(
4194 "SetSshdOption",
4195 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
4196 )
4197 .unwrap_err(),
4198 ExecutorError::InvalidParam("option")
4199 ));
4200 assert!(matches!(
4202 build_action_spec(
4203 "SetSshdOption",
4204 &json!({ "option": "PermitRootLogin", "value": "maybe" })
4205 )
4206 .unwrap_err(),
4207 ExecutorError::InvalidParam("value")
4208 ));
4209 let spec = build_action_spec(
4211 "SetSshdOption",
4212 &json!({ "option": "PasswordAuthentication", "value": "no" }),
4213 )
4214 .unwrap();
4215 assert_eq!(
4216 spec.mechanism,
4217 ActionMechanism::Command {
4218 program: "sudo",
4219 args: vec![
4220 "/usr/lib/sysknife/sshd-option-edit".to_string(),
4221 "--option".to_string(),
4222 "PasswordAuthentication".to_string(),
4223 "--value".to_string(),
4224 "no".to_string(),
4225 ],
4226 }
4227 );
4228 }
4229
4230 #[test]
4231 fn configure_unattended_upgrades_toggles_helper_flag() {
4232 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
4233 let spec = build_action_spec(
4234 "ConfigureUnattendedUpgrades",
4235 &json!({ "enabled": enabled }),
4236 )
4237 .unwrap();
4238 assert_eq!(
4239 spec.mechanism,
4240 ActionMechanism::Command {
4241 program: "sudo",
4242 args: vec![
4243 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
4244 flag.to_string(),
4245 ],
4246 }
4247 );
4248 }
4249 assert!(matches!(
4250 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
4251 ExecutorError::MissingParam("enabled")
4252 ));
4253 }
4254
4255 #[test]
4256 fn grub_set_kargs_append_blocks_single_user_shortcut() {
4257 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
4265 let err = build_action_spec(
4266 "GrubSetKargs",
4267 &json!({ "append": [dangerous], "delete": [] }),
4268 )
4269 .unwrap_err();
4270 assert!(
4271 matches!(err, ExecutorError::InvalidParam("append")),
4272 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
4273 );
4274 }
4275 assert!(build_action_spec(
4277 "GrubSetKargs",
4278 &json!({ "append": ["quiet"], "delete": [] })
4279 )
4280 .is_ok());
4281 }
4282
4283 #[test]
4284 fn kernel_arg_build_spec_rejects_dangerous_arg() {
4285 let err = build_action_spec(
4291 "SetKernelArguments",
4292 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
4293 )
4294 .unwrap_err();
4295 assert!(
4296 matches!(err, ExecutorError::InvalidParam("add")),
4297 "expected InvalidParam(add), got {err:?}"
4298 );
4299 }
4300
4301 #[test]
4302 fn grub_set_kargs_cannot_delete_a_protective_karg() {
4303 for karg in [
4310 "apparmor=1",
4311 "lockdown=confidentiality",
4312 "mitigations=auto",
4313 "pti=on",
4314 "selinux=1",
4315 ] {
4316 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
4317 .unwrap_err();
4318 assert!(
4319 matches!(err, ExecutorError::InvalidParam("delete")),
4320 "deleting {karg:?} must be refused, got {err:?}"
4321 );
4322 }
4323 }
4324
4325 #[test]
4330 fn rollback_available_matches_rollback_spec_for_all_actions() {
4331 for spec in crate::actions::all_specs() {
4339 let has_rollback = rollback_spec_for(spec.action_name).is_some();
4340 assert_eq!(
4341 spec.rollback_available,
4342 has_rollback,
4343 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
4344 spec.action_name,
4345 spec.rollback_available,
4346 if has_rollback { "Some" } else { "None" },
4347 );
4348 }
4349 }
4350
4351 #[test]
4359 fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
4360 for home in [
4361 "../../etc/skel",
4362 "/home/../etc",
4363 "home/alice",
4364 "/home/alice/../../../root",
4365 ] {
4366 let got =
4367 build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
4368 assert!(
4369 got.is_err(),
4370 "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
4371 );
4372 }
4373 build_action_spec(
4376 "CreateUser",
4377 &json!({ "username": "alice", "home": "/home/alice" }),
4378 )
4379 .expect("a plain absolute home must still be accepted");
4380 }
4381
4382 #[test]
4388 fn create_user_is_high_risk() {
4389 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
4390 assert_eq!(
4391 spec.risk_level,
4392 RiskLevel::High,
4393 "CreateUser must be High (T1136.001 Persistence)"
4394 );
4395 }
4396
4397 #[test]
4398 fn configure_firewall_is_high_risk() {
4399 let spec = build_action_spec(
4400 "ConfigureFirewall",
4401 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
4402 )
4403 .unwrap();
4404 assert_eq!(
4405 spec.risk_level,
4406 RiskLevel::High,
4407 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
4408 );
4409 }
4410
4411 #[test]
4412 fn mask_service_is_high_risk() {
4413 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
4414 assert_eq!(
4415 spec.risk_level,
4416 RiskLevel::High,
4417 "MaskService must be High (T1562.001 Impair Defenses)"
4418 );
4419 }
4420
4421 #[test]
4422 fn add_package_repository_is_high_risk() {
4423 let spec = build_action_spec(
4424 "AddPackageRepository",
4425 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
4426 )
4427 .unwrap();
4428 assert_eq!(
4429 spec.risk_level,
4430 RiskLevel::High,
4431 "AddPackageRepository must be High (supply-chain vector)"
4432 );
4433 }
4434
4435 #[test]
4436 fn set_dns_servers_is_high_risk() {
4437 let spec = build_action_spec(
4438 "SetDnsServers",
4439 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
4440 )
4441 .unwrap();
4442 assert_eq!(
4443 spec.risk_level,
4444 RiskLevel::High,
4445 "SetDnsServers must be High (DNS hijacking / T1557)"
4446 );
4447 }
4448}