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" => &["limit", "status_filter", "action_filter", "since_hours"],
786 _ => return None,
787 })
788}
789
790pub(crate) fn validate_action_params(
791 action_name: &str,
792 params: &Value,
793) -> Result<(), ExecutorError> {
794 let allowed = allowed_action_params(action_name)
795 .ok_or_else(|| ExecutorError::UnknownAction(action_name.to_string()))?;
796 let object = params
797 .as_object()
798 .ok_or(ExecutorError::InvalidParam("params"))?;
799 if object.keys().any(|key| !allowed.contains(&key.as_str())) {
800 return Err(ExecutorError::InvalidParam("unknown parameter"));
801 }
802 Ok(())
803}
804
805pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
811 validate_action_params(action_name, params)?;
812 match action_name {
813 "GetSystemState" => Ok(deployment::get_system_state()),
815 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
816 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
817 "ListDeployments" => Ok(deployment::list_deployments()),
818 "UpdateSystem" => Ok(deployment::update_system()),
819 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
820 "RebootSystem" => Ok(deployment::reboot_system()),
821 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
822 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
823
824 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
826 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
827 "RebaseSystem" => {
828 let target_ref = require_str(params, "target_ref")?;
829 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
830 Ok(deployment::rebase_system(&target_ref))
831 }
832 "SetKernelArguments" => {
833 let add = str_array_or_empty(params, "add")?;
834 let remove = str_array_or_empty(params, "remove")?;
835 for arg in add.iter() {
838 validated_safe_kernel_arg(arg, "add")?;
839 }
840 for arg in remove.iter() {
843 validated_safe_kernel_arg_removal(arg, "remove")?;
844 }
845 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
846 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
847 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
848 }
849
850 "ListFlatpakRemotes" => {
856 let username = validated_username(resolve_username(params)?, "username")?;
857 Ok(flatpak::list_flatpak_remotes(&username))
858 }
859 "InstallFlatpak" => {
860 let username = validated_username(resolve_username(params)?, "username")?;
861 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
862 let remote = params
866 .get("remote")
867 .and_then(|v| v.as_str())
868 .unwrap_or("flathub");
869 let remote = validated_safe_arg(remote, "remote")?;
870 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
871 }
872 "RemoveFlatpak" => {
873 let username = validated_username(resolve_username(params)?, "username")?;
874 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
875 Ok(flatpak::remove_flatpak(&username, &app_id))
876 }
877 "SearchFlatpakApps" => {
878 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
879 Ok(flatpak::search_flatpak_apps(&term))
880 }
881 "AddFlatpakRemote" => {
882 let username = validated_username(resolve_username(params)?, "username")?;
883 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
884 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
885 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
886 }
887 "RemoveFlatpakRemote" => {
888 let username = validated_username(resolve_username(params)?, "username")?;
889 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
890 Ok(flatpak::remove_flatpak_remote(&username, &remote))
891 }
892 "GetFlatpakAppInfo" => {
893 let username = validated_username(resolve_username(params)?, "username")?;
894 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
895 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
896 }
897 "ListInstalledFlatpaks" => {
898 let username = validated_username(resolve_username(params)?, "username")?;
899 Ok(flatpak::list_installed_flatpaks(&username))
900 }
901 "UpdateFlatpak" => {
902 let username = validated_username(resolve_username(params)?, "username")?;
903 let app_id = params
906 .get("app_id")
907 .and_then(|v| v.as_str())
908 .filter(|id| !id.is_empty())
909 .map(|id| validated_safe_arg(id, "app_id"))
910 .transpose()?;
911 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
912 }
913
914 "ListContainers" => {
920 let username = validated_username(resolve_username(params)?, "username")?;
921 Ok(containers::list_containers(&username))
922 }
923 "CreateContainer" => {
924 let username = validated_username(resolve_username(params)?, "username")?;
925 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
926 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
927 Ok(containers::create_container(&username, &name, &image))
928 }
929 "StartContainer" => {
930 let username = validated_username(resolve_username(params)?, "username")?;
931 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
932 Ok(containers::start_container(&username, &name))
933 }
934 "StopContainer" => {
935 let username = validated_username(resolve_username(params)?, "username")?;
936 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
937 Ok(containers::stop_container(&username, &name))
938 }
939 "RemoveContainer" => {
940 let username = validated_username(resolve_username(params)?, "username")?;
941 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
942 Ok(containers::remove_container(&username, &name))
943 }
944 "GetContainerInfo" => {
945 let username = validated_username(resolve_username(params)?, "username")?;
946 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
947 Ok(containers::get_container_info(&username, &name))
948 }
949
950 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
952 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
953 "InstallPackages" => {
954 let pkgs = str_array_or_empty(params, "packages")?;
955 let validated: Vec<String> = pkgs
956 .iter()
957 .map(|p| validated_install_package(p, "packages"))
958 .collect::<Result<_, _>>()?;
959 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
960 Ok(layering::install_packages(&refs))
961 }
962 "RemovePackages" => {
963 let pkgs = str_array_or_empty(params, "packages")?;
964 let validated: Vec<String> = pkgs
965 .iter()
966 .map(|p| validated_install_package(p, "packages"))
967 .collect::<Result<_, _>>()?;
968 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
969 Ok(layering::remove_packages(&refs))
970 }
971 "AddLayeredPackage" => {
972 let package = validated_install_package(require_str(params, "package")?, "package")?;
973 Ok(layering::add_layered_package(&package))
974 }
975 "RemoveLayeredPackage" => {
976 let package = validated_install_package(require_str(params, "package")?, "package")?;
977 Ok(layering::remove_layered_package(&package))
978 }
979 "ReplaceLayeredPackage" => {
980 let old = validated_install_package(require_str(params, "old")?, "old")?;
981 let new = validated_install_package(require_str(params, "new")?, "new")?;
982 Ok(layering::replace_layered_package(&old, &new))
983 }
984 "RemoveBasePackage" => {
985 let package = validated_install_package(require_str(params, "package")?, "package")?;
986 Ok(layering::remove_base_package(&package))
987 }
988 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
989
990 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
992 "AddPackageRepository" => Ok(package_repos::add_package_repository(
993 validated_repo_id(params)?,
994 validated_no_newline(params, "repo_url")?,
995 )),
996 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
997 validated_repo_id(params)?,
998 )),
999 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
1000 validated_repo_id(params)?,
1001 )),
1002 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
1003 validated_repo_id(params)?,
1004 )),
1005
1006 "ListServices" => Ok(services::list_services()),
1008 "StartService" => {
1009 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1010 Ok(services::start_service(&unit))
1011 }
1012 "StopService" => {
1013 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1014 Ok(services::stop_service(&unit))
1015 }
1016 "RestartService" => {
1017 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1018 Ok(services::restart_service(&unit))
1019 }
1020 "SetServiceEnabled" => {
1021 let enabled = require_bool(params, "enabled")?;
1022 let unit = if enabled {
1025 validated_activatable_unit(require_str(params, "unit")?, "unit")?
1026 } else {
1027 validated_unit_name(require_str(params, "unit")?, "unit")?
1028 };
1029 Ok(services::set_service_enabled(&unit, enabled))
1030 }
1031 "MaskService" => {
1032 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1033 Ok(services::mask_service(&unit))
1034 }
1035 "UnmaskService" => {
1036 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
1037 Ok(services::unmask_service(&unit))
1038 }
1039 "GetServiceLogs" => {
1040 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1041 Ok(services::get_service_logs(&unit))
1042 }
1043 "GetServiceStatus" => {
1044 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1045 Ok(services::get_service_status(&unit))
1046 }
1047 "ReloadService" => {
1048 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1049 Ok(services::reload_service(&unit))
1050 }
1051 "ListTimers" => Ok(services::list_timers()),
1052 "ReloadDaemon" => Ok(services::reload_daemon()),
1053 "CreateScheduledJob" => {
1054 let name = require_str(params, "name")?;
1057 if services::scheduled_job_unit_paths(name).is_none() {
1058 return Err(ExecutorError::InvalidParam("name"));
1059 }
1060 let command = require_str(params, "command")?;
1063 if command.is_empty()
1064 || command.len() > MAX_SCHEDULED_COMMAND_LEN
1065 || command.chars().any(|c| c.is_control())
1066 {
1067 return Err(ExecutorError::InvalidParam("command"));
1068 }
1069 let schedule = require_str(params, "schedule")?;
1072 if schedule.is_empty()
1073 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
1074 || !schedule.chars().all(|c| {
1075 c.is_ascii_alphanumeric()
1076 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
1077 })
1078 {
1079 return Err(ExecutorError::InvalidParam("schedule"));
1080 }
1081 Ok(services::create_scheduled_job(name, command, schedule))
1082 }
1083 "GetServiceResourceLimits" => {
1084 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
1085 Ok(services::get_service_resource_limits(&unit))
1086 }
1087 "SetServiceResourceLimits" => {
1088 let unit = validated_resource_limit_unit(require_str(params, "unit")?, "unit")?;
1092 let mut assignments = Vec::new();
1095 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
1096 assignments.push(format!("MemoryMax={v}"));
1097 }
1098 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
1099 assignments.push(format!("MemoryHigh={v}"));
1100 }
1101 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
1102 assignments.push(format!("CPUQuota={v}"));
1103 }
1104 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
1105 assignments.push(format!("TasksMax={v}"));
1106 }
1107 if assignments.is_empty() {
1108 return Err(ExecutorError::MissingParam("memory_max"));
1109 }
1110 Ok(services::set_service_resource_limits(&unit, &assignments))
1111 }
1112
1113 "ListToolboxes" => {
1117 let username = validated_username(resolve_username(params)?, "username")?;
1118 Ok(toolbox::list_toolboxes(&username))
1119 }
1120 "CreateToolbox" => {
1121 let username = validated_username(resolve_username(params)?, "username")?;
1122 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1123 let image = params
1124 .get("image")
1125 .and_then(|v| v.as_str())
1126 .map(|img| validated_safe_arg(img, "image"))
1127 .transpose()?;
1128 let release = params
1129 .get("release")
1130 .and_then(|v| v.as_str())
1131 .map(|r| validated_safe_arg(r, "release"))
1132 .transpose()?;
1133 Ok(toolbox::create_toolbox(
1134 &username,
1135 &name,
1136 release.as_deref(),
1137 image.as_deref(),
1138 ))
1139 }
1140 "RemoveToolbox" => {
1141 let username = validated_username(resolve_username(params)?, "username")?;
1142 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1143 Ok(toolbox::remove_toolbox(&username, &name))
1144 }
1145
1146 "GetDateTime" => Ok(identity::get_datetime()),
1148 "SetHostname" => {
1149 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
1150 Ok(identity::set_hostname(&hostname))
1151 }
1152 "SetTimezone" => {
1153 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
1154 Ok(identity::set_timezone(&timezone))
1155 }
1156 "SetLocale" => {
1157 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
1158 Ok(identity::set_locale(&locale))
1159 }
1160 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
1161
1162 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
1164
1165 "ListProcesses" => Ok(processes::list_processes_spec()),
1167 "SignalProcess" => {
1168 let pid = params
1170 .get("pid")
1171 .and_then(|v| {
1172 v.as_u64()
1173 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
1174 })
1175 .ok_or(ExecutorError::MissingParam("pid"))?;
1176 if pid < 2 || pid > u32::MAX as u64 {
1179 return Err(ExecutorError::InvalidParam("pid"));
1180 }
1181 let signal = validated_kill_signal(
1182 params
1183 .get("signal")
1184 .and_then(|v| v.as_str())
1185 .unwrap_or("TERM"),
1186 )?;
1187 Ok(processes::signal_process(pid as u32, signal))
1188 }
1189
1190 "GetJournalLog" => {
1192 let unit = optional_validated(params, "unit", validated_unit_name)?;
1193 let priority = optional_validated(params, "priority", validated_journal_priority)?;
1194 let since = optional_validated(params, "since", validated_journal_time)?;
1195 let until = optional_validated(params, "until", validated_journal_time)?;
1196 let grep = optional_validated(params, "grep", validated_journal_grep)?;
1197 let lines = params
1200 .get("lines")
1201 .and_then(|v| v.as_u64())
1202 .unwrap_or(100)
1203 .clamp(1, MAX_JOURNAL_LINES) as u32;
1204 let boot = params
1205 .get("boot")
1206 .and_then(|v| v.as_bool())
1207 .unwrap_or(false);
1208 let kernel = params
1209 .get("kernel")
1210 .and_then(|v| v.as_bool())
1211 .unwrap_or(false);
1212 Ok(journald::get_journal_log(&journald::JournalQuery {
1213 lines,
1214 unit: unit.as_deref(),
1215 priority: priority.as_deref(),
1216 boot,
1217 kernel,
1218 since: since.as_deref(),
1219 until: until.as_deref(),
1220 grep: grep.as_deref(),
1221 }))
1222 }
1223 "VacuumJournal" => {
1224 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
1226 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
1227 match (size_mb, retain_days) {
1228 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
1229 Ok(journald::vacuum_journal_by_size(mb as u32))
1230 }
1231 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
1232 Ok(journald::vacuum_journal_by_time(days as u32))
1233 }
1234 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
1235 _ => Err(ExecutorError::InvalidParam("size_mb")),
1237 }
1238 }
1239
1240 "GetLvmReport" => Ok(lvm::get_lvm_report()),
1242 "ExtendLogicalVolume" => {
1243 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1244 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
1245 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1246 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
1247 }
1248 "CreateLogicalVolume" => {
1249 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1250 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
1251 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1252 Ok(lvm::create_logical_volume(&vg, &name, &size))
1253 }
1254 "CreateLvSnapshot" => {
1255 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
1256 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
1257 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
1258 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
1259 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
1260 }
1261
1262 "GetSysctl" => {
1264 let key = optional_validated(params, "key", validated_sysctl_key)?;
1266 Ok(sysctl::get_sysctl(key.as_deref()))
1267 }
1268 "SetSysctl" => {
1269 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
1270 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
1271 Ok(sysctl::set_sysctl(&key, &value))
1272 }
1273
1274 "GetMounts" => Ok(mounts::get_mounts()),
1276 "AddMount" => {
1277 let device = validated_mount_device(require_str(params, "device")?, "device")?;
1278 let mountpoint =
1279 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1280 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
1281 let options = optional_validated(params, "options", validated_mount_options)?;
1282 Ok(mounts::add_mount(
1283 &device,
1284 &mountpoint,
1285 &fstype,
1286 options.as_deref(),
1287 ))
1288 }
1289 "RemoveMount" => {
1290 let mountpoint =
1291 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
1292 Ok(mounts::remove_mount(&mountpoint))
1293 }
1294 "AddSwap" => {
1295 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1296 let size_mb = require_u32(params, "size_mb")?;
1297 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
1299 return Err(ExecutorError::InvalidParam("size_mb"));
1300 }
1301 Ok(mounts::add_swap(&file, size_mb))
1302 }
1303 "RemoveSwap" => {
1304 let file = validated_swap_path(require_str(params, "file")?, "file")?;
1305 Ok(mounts::remove_swap(&file))
1306 }
1307
1308 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
1310 "GrantSudoAccess" => {
1311 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1312 let user = validated_username(require_str(params, "user")?, "user")?;
1313 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
1314 let runas = match params
1316 .get("runas")
1317 .and_then(|v| v.as_str())
1318 .filter(|s| !s.is_empty())
1319 {
1320 Some("ALL") => Some("ALL".to_string()),
1321 Some(u) => Some(validated_username(u, "runas")?),
1322 None => None,
1323 };
1324 let nopasswd = params
1325 .get("nopasswd")
1326 .and_then(|v| v.as_bool())
1327 .unwrap_or(false);
1328 let shell_equivalent = shell_equivalent_sudo_command(&commands);
1347 if (commands == "ALL" || shell_equivalent.is_some()) && nopasswd {
1348 return Err(ExecutorError::InvalidParam("commands"));
1349 }
1350 Ok(sudoers::grant_sudo_access(
1351 &name,
1352 &user,
1353 &commands,
1354 runas.as_deref(),
1355 nopasswd,
1356 ))
1357 }
1358 "RevokeSudoAccess" => {
1359 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1360 Ok(sudoers::revoke_sudo_access(&name))
1361 }
1362
1363 "GetAptPins" => {
1365 let package = optional_validated(params, "package", validated_apt_package)?;
1366 Ok(apt_preferences::get_apt_pins(package.as_deref()))
1367 }
1368 "SetAptPin" => {
1369 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1370 let package = validated_apt_package(require_str(params, "package")?, "package")?;
1371 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
1372 let priority = params
1373 .get("priority")
1374 .and_then(|v| v.as_i64())
1375 .ok_or(ExecutorError::MissingParam("priority"))?;
1376 if !(-1..=1000).contains(&priority) {
1377 return Err(ExecutorError::InvalidParam("priority"));
1378 }
1379 Ok(apt_preferences::set_apt_pin(
1380 &name, &package, &pin, priority,
1381 ))
1382 }
1383 "RemoveAptPin" => {
1384 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1385 Ok(apt_preferences::remove_apt_pin(&name))
1386 }
1387
1388 "GetLogrotateStatus" => {
1390 let config = optional_validated(params, "config", validated_log_path)?;
1391 Ok(logging::get_logrotate_status(config.as_deref()))
1392 }
1393 "ConfigureLogRotation" => {
1394 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1395 let path = validated_log_path(require_str(params, "path")?, "path")?;
1396 let frequency = require_str(params, "frequency")?;
1397 if !matches!(frequency, "daily" | "weekly" | "monthly") {
1398 return Err(ExecutorError::InvalidParam("frequency"));
1399 }
1400 let rotate = require_u32(params, "rotate")?;
1401 if rotate > 1000 {
1402 return Err(ExecutorError::InvalidParam("rotate"));
1403 }
1404 let compress = params
1405 .get("compress")
1406 .and_then(|v| v.as_bool())
1407 .unwrap_or(false);
1408 Ok(logging::configure_log_rotation(
1409 &name, &path, frequency, rotate, compress,
1410 ))
1411 }
1412 "RemoveLogRotation" => {
1413 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1414 Ok(logging::remove_log_rotation(&name))
1415 }
1416 "ConfigureRemoteSyslog" => {
1417 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1418 let port = require_u32(params, "port")?;
1419 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1420 return Err(ExecutorError::InvalidParam("port"));
1421 }
1422 let protocol = require_str(params, "protocol")?;
1423 if !matches!(protocol, "tcp" | "udp") {
1424 return Err(ExecutorError::InvalidParam("protocol"));
1425 }
1426 Ok(logging::configure_remote_syslog(
1427 &host,
1428 port as u16,
1429 protocol,
1430 ))
1431 }
1432 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1433
1434 "GetPasswordAging" => {
1436 let user = validated_username(require_str(params, "user")?, "user")?;
1437 Ok(pam::get_password_aging(&user))
1438 }
1439 "SetPasswordAging" => {
1440 let user = validated_username(require_str(params, "user")?, "user")?;
1441 let mut flags = Vec::new();
1443 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1444 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1445 if n > MAX_PASSWORD_AGE_DAYS {
1446 return Err(ExecutorError::InvalidParam(key));
1447 }
1448 flags.push(opt.to_string());
1449 flags.push(n.to_string());
1450 }
1451 }
1452 if flags.is_empty() {
1453 return Err(ExecutorError::MissingParam("max_days"));
1454 }
1455 Ok(pam::set_password_aging(&user, &flags))
1456 }
1457 "SetPasswordPolicy" => {
1458 let mut extra = Vec::new();
1461 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1462 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1463 return Err(ExecutorError::InvalidParam("minlen"));
1464 }
1465 extra.push("--minlen".to_string());
1466 extra.push(n.to_string());
1467 }
1468 for (key, flag) in [
1469 ("dcredit", "--dcredit"),
1470 ("ucredit", "--ucredit"),
1471 ("lcredit", "--lcredit"),
1472 ("ocredit", "--ocredit"),
1473 ] {
1474 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1475 if !(-64..=64).contains(&n) {
1476 return Err(ExecutorError::InvalidParam(key));
1477 }
1478 extra.push(flag.to_string());
1479 extra.push(n.to_string());
1480 }
1481 }
1482 if extra.is_empty() {
1483 return Err(ExecutorError::MissingParam("minlen"));
1484 }
1485 Ok(pam::set_password_policy(&extra))
1486 }
1487 "SetAccountLockout" => {
1488 let mut extra = Vec::new();
1490 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1491 if !(1..=1000).contains(&n) {
1492 return Err(ExecutorError::InvalidParam("deny"));
1493 }
1494 extra.push("--deny".to_string());
1495 extra.push(n.to_string());
1496 }
1497 for (key, flag) in [
1498 ("unlock_time", "--unlock-time"),
1499 ("fail_interval", "--fail-interval"),
1500 ] {
1501 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1502 if n > MAX_LOCKOUT_WINDOW_SECS {
1503 return Err(ExecutorError::InvalidParam(key));
1504 }
1505 extra.push(flag.to_string());
1506 extra.push(n.to_string());
1507 }
1508 }
1509 if extra.is_empty() {
1510 return Err(ExecutorError::MissingParam("deny"));
1511 }
1512 Ok(pam::set_account_lockout(&extra))
1513 }
1514
1515 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1517 "GetHostState" => Ok(system_info::get_host_state()),
1518
1519 "GetFirewallState" => Ok(network::get_firewall_state()),
1521 "GetNetworkStatus" => Ok(network::get_network_status()),
1522 "GetListeningPorts" => Ok(network::get_listening_ports()),
1523 "GetNftablesRuleset" => Ok(network::get_nftables_ruleset()),
1524 "GetFirewallBackendState" => Ok(network::get_firewall_backend_state()),
1525 "ConfigureWifi" => {
1526 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1527 let password = params
1529 .get("password")
1530 .and_then(|v| v.as_str())
1531 .filter(|p| !p.is_empty())
1532 .map(|p| validated_safe_arg(p, "password"))
1533 .transpose()?;
1534 Ok(network::configure_wifi(&ssid, password.as_deref()))
1535 }
1536 "SetDnsServers" => {
1537 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1538 let servers = str_array_or_empty(params, "servers")?;
1539 let validated: Vec<String> = servers
1540 .iter()
1541 .map(|s| validated_safe_arg(s, "servers"))
1542 .collect::<Result<_, _>>()?;
1543 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1544 Ok(network::set_dns_servers(&interface, &refs))
1545 }
1546 "ConfigureFirewall" => {
1547 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1548 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1549 Ok(network::configure_firewall(
1550 &zone,
1551 &service,
1552 require_bool(params, "enabled")?,
1553 ))
1554 }
1555
1556 "ListUsers" => Ok(users::list_users()),
1558 "ListGroups" => Ok(users::list_groups()),
1559 "CreateUser" => {
1560 let username = validated_username(resolve_username(params)?, "username")?;
1561 let shell = params
1562 .get("shell")
1563 .and_then(|v| v.as_str())
1564 .map(|s| validated_safe_arg(s, "shell"))
1565 .transpose()?;
1566 let home = params
1567 .get("home")
1568 .and_then(|v| v.as_str())
1569 .map(|h| validated_home_dir(h, "home"))
1570 .transpose()?;
1571 Ok(users::create_user(
1572 &username,
1573 shell.as_deref(),
1574 home.as_deref(),
1575 ))
1576 }
1577 "DeleteUser" => {
1578 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1579 Ok(users::delete_user(&username))
1580 }
1581 "AddUserToGroup" => {
1582 let username = validated_username(resolve_username(params)?, "username")?;
1583 let group = validated_group(require_str(params, "group")?, "group")?;
1584 Ok(users::add_user_to_group(&username, &group))
1585 }
1586 "RemoveUserFromGroup" => {
1587 let username = validated_username(resolve_username(params)?, "username")?;
1588 let group = validated_group(require_str(params, "group")?, "group")?;
1589 Ok(users::remove_user_from_group(&username, &group))
1590 }
1591 "CreateGroup" => {
1592 let group = validated_group(require_str(params, "group")?, "group")?;
1593 let system = params
1594 .get("system")
1595 .and_then(|v| v.as_bool())
1596 .unwrap_or(false);
1597 Ok(users::create_group(&group, system))
1598 }
1599 "DeleteGroup" => {
1600 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1601 Ok(users::delete_group(&group))
1602 }
1603 "LockUserAccount" => {
1604 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1605 Ok(users::lock_user_account(&username))
1606 }
1607 "UnlockUserAccount" => {
1608 let username = validated_username(resolve_username(params)?, "username")?;
1609 Ok(users::unlock_user_account(&username))
1610 }
1611
1612 "GetAuthorizedKeys" => {
1614 let username = validated_username(resolve_username(params)?, "username")?;
1615 Ok(ssh::get_authorized_keys(&username))
1616 }
1617 "AddAuthorizedKey" => {
1618 let username = validated_username(resolve_username(params)?, "username")?;
1619 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1620 Ok(ssh::add_authorized_key(&username, &public_key))
1621 }
1622 "RemoveAuthorizedKey" => {
1623 let username = validated_username(resolve_username(params)?, "username")?;
1624 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1625 Ok(ssh::remove_authorized_key(&username, &public_key))
1626 }
1627 "SetSshdOption" => {
1628 let option = require_str(params, "option")?;
1632 let value = require_str(params, "value")?;
1633 let allowed_values: &[&str] = match option {
1634 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1635 "PasswordAuthentication"
1636 | "PubkeyAuthentication"
1637 | "X11Forwarding"
1638 | "PermitEmptyPasswords" => &["yes", "no"],
1639 _ => return Err(ExecutorError::InvalidParam("option")),
1640 };
1641 if !allowed_values.contains(&value) {
1642 return Err(ExecutorError::InvalidParam("value"));
1643 }
1644 Ok(ssh::set_sshd_option(option, value))
1645 }
1646
1647 "AptUpdate" => Ok(apt::apt_update()),
1649 "AptUpgrade" => Ok(apt::apt_upgrade()),
1650 "AptInstall" => {
1651 let package = validated_install_package(require_str(params, "package")?, "package")?;
1652 Ok(apt::apt_install(&package))
1653 }
1654 "AptRemove" => {
1655 let package = validated_install_package(require_str(params, "package")?, "package")?;
1656 Ok(apt::apt_remove(&package))
1657 }
1658 "AptPurge" => {
1659 let package = validated_install_package(require_str(params, "package")?, "package")?;
1660 Ok(apt::apt_purge(&package))
1661 }
1662 "AptAutoremove" => Ok(apt::apt_autoremove()),
1663 "AptHold" => {
1664 let package = validated_install_package(require_str(params, "package")?, "package")?;
1665 Ok(apt::apt_hold(&package))
1666 }
1667 "AptUnhold" => {
1668 let package = validated_install_package(require_str(params, "package")?, "package")?;
1669 Ok(apt::apt_unhold(&package))
1670 }
1671 "AptSearch" => {
1672 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1673 Ok(apt::apt_search(&term))
1674 }
1675 "AptListInstalled" => Ok(apt::apt_list_installed()),
1676 "AptShow" => {
1677 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1678 Ok(apt::apt_show(&package))
1679 }
1680 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1681 "AptHistoryList" => Ok(apt::apt_history_list()),
1682 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1683 params, "enabled",
1684 )?)),
1685
1686 "AddPpa" => {
1688 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1689 Ok(ppa::add_ppa(&name))
1690 }
1691 "RemovePpa" => {
1692 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1693 Ok(ppa::remove_ppa(&name))
1694 }
1695
1696 "SnapInstall" => {
1698 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1699 let channel = params
1700 .get("channel")
1701 .and_then(|v| v.as_str())
1702 .filter(|s| !s.is_empty())
1703 .map(|s| validated_safe_arg(s, "channel"))
1704 .transpose()?;
1705 let auto_update = params
1706 .get("auto_update")
1707 .and_then(|v| v.as_bool())
1708 .unwrap_or(false);
1709 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1710 snap::SnapInstallError::InvalidArg { param, .. } => {
1711 ExecutorError::InvalidParam(param)
1712 }
1713 })
1714 }
1715 "SnapRemove" => {
1716 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1717 Ok(snap::snap_remove(&name))
1718 }
1719 "SnapRefresh" => {
1720 let name = params
1721 .get("name")
1722 .and_then(|v| v.as_str())
1723 .filter(|s| !s.is_empty())
1724 .map(|s| validated_safe_arg(s, "name"))
1725 .transpose()?;
1726 Ok(snap::snap_refresh(name.as_deref()))
1727 }
1728 "SnapHold" => {
1729 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1730 Ok(snap::snap_hold(&name))
1731 }
1732 "SnapUnhold" => {
1733 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1734 Ok(snap::snap_unhold(&name))
1735 }
1736 "SnapList" => Ok(snap::snap_list()),
1737 "SnapInfo" => {
1738 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1739 Ok(snap::snap_info(&name))
1740 }
1741 "SnapRevert" => {
1742 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1743 Ok(snap::snap_revert(&name))
1744 }
1745 "SnapClassicInstall" => {
1746 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1747 Ok(snap::snap_classic_install(&name))
1748 }
1749
1750 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1752 "GrubSetKargs" => {
1753 let append: Vec<String> = params
1754 .get("append")
1755 .and_then(|v| v.as_array())
1756 .map(|arr| {
1757 arr.iter()
1758 .filter_map(|v| v.as_str())
1759 .map(String::from)
1760 .collect()
1761 })
1762 .unwrap_or_default();
1763 let delete: Vec<String> = params
1764 .get("delete")
1765 .and_then(|v| v.as_array())
1766 .map(|arr| {
1767 arr.iter()
1768 .filter_map(|v| v.as_str())
1769 .map(String::from)
1770 .collect()
1771 })
1772 .unwrap_or_default();
1773 for a in &append {
1782 validated_safe_arg(a, "append")?;
1783 validated_safe_kernel_arg(a, "append")?;
1784 }
1785 for d in &delete {
1786 validated_safe_arg(d, "delete")?;
1787 validated_safe_kernel_arg_removal(d, "delete")?;
1788 }
1789 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1792 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1793 grub::grub_set_kargs(&append_refs, &delete_refs)
1794 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1795 }
1796
1797 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1799
1800 "UfwEnable" => Ok(ufw::ufw_enable()),
1802 "UfwDisable" => Ok(ufw::ufw_disable()),
1803 "UfwAllow" => {
1804 let port_or_service = validated_port_or_service(
1805 require_str(params, "port_or_service")?,
1806 "port_or_service",
1807 )?;
1808 Ok(ufw::ufw_allow(&port_or_service))
1809 }
1810 "UfwDeny" => {
1811 let port_or_service = validated_port_or_service(
1812 require_str(params, "port_or_service")?,
1813 "port_or_service",
1814 )?;
1815 Ok(ufw::ufw_deny(&port_or_service))
1816 }
1817 "UfwReset" => Ok(ufw::ufw_reset()),
1818 "UfwStatus" => {
1819 let numbered = match params.get("numbered") {
1820 None => false,
1821 Some(value) => value
1822 .as_bool()
1823 .ok_or(ExecutorError::InvalidParam("numbered"))?,
1824 };
1825 Ok(ufw::ufw_status_with_numbered(numbered))
1826 }
1827
1828 "DistroboxList" => Ok(distrobox::distrobox_list()),
1830 "DistroboxCreate" => {
1831 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1832 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1833 Ok(distrobox::distrobox_create(&name, &image))
1834 }
1835 "DistroboxRemove" => {
1836 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1837 Ok(distrobox::distrobox_remove(&name))
1838 }
1839
1840 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1842 "NetplanApply" => Ok(netplan::netplan_apply()),
1843 "NetplanSet" => {
1844 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1845 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1846 Ok(netplan::netplan_set(&key, &value))
1847 }
1848 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1849
1850 "UfwDeleteRule" => {
1852 let rule_number = require_positive_u32(params, "rule_number")?;
1853 ufw::ufw_delete_rule(rule_number)
1854 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1855 }
1856 "UfwLimit" => {
1857 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1858 Ok(ufw::ufw_limit(&target))
1859 }
1860
1861 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1863 "ProAttach" => {
1864 let token = require_str(params, "token")?;
1866 let token = validated_safe_arg(token, "token")?;
1868 Ok(ubuntu_pro::pro_attach(&token))
1869 }
1870 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1871 "EnableProService" => {
1872 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1873 Ok(ubuntu_pro::enable_pro_service(&service))
1874 }
1875 "DisableProService" => {
1876 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1877 Ok(ubuntu_pro::disable_pro_service(&service))
1878 }
1879
1880 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1882
1883 "MultipassList" => Ok(multipass::multipass_list()),
1885
1886 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1888
1889 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1891 "ResolvectlSetDns" => {
1892 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1893 let raw_servers: Vec<String> = params
1894 .get("servers")
1895 .and_then(|v| v.as_array())
1896 .map(|arr| {
1897 arr.iter()
1898 .filter_map(|v| v.as_str())
1899 .map(String::from)
1900 .collect()
1901 })
1902 .unwrap_or_default();
1903 if raw_servers.is_empty() {
1904 return Err(ExecutorError::MissingParam("servers"));
1905 }
1906 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1910 for s in &raw_servers {
1911 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1912 param: "servers",
1913 value: s.clone(),
1914 })?;
1915 parsed_servers.push(addr);
1916 }
1917 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1918 }
1919
1920 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1922 "AppArmorEnforce" => {
1923 let profile_path =
1924 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1925 Ok(apparmor::apparmor_enforce(&profile_path))
1926 }
1927 "AppArmorComplain" => {
1928 let profile_path =
1929 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1930 Ok(apparmor::apparmor_complain(&profile_path))
1931 }
1932
1933 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1935
1936 "UbuntuInstallFlatpak" => {
1938 let username = validated_username(resolve_username(params)?, "username")?;
1939 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1940 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1941 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1942 }
1943 "UbuntuRemoveFlatpak" => {
1944 let username = validated_username(resolve_username(params)?, "username")?;
1945 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1946 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1947 }
1948 "UbuntuUpdateFlatpak" => {
1949 let username = validated_username(resolve_username(params)?, "username")?;
1950 let app_id = params
1951 .get("app_id")
1952 .and_then(|v| v.as_str())
1953 .filter(|s| !s.is_empty())
1954 .map(|s| validated_safe_arg(s, "app_id"))
1955 .transpose()?;
1956 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1957 }
1958 "UbuntuListFlatpaks" => {
1959 let username = validated_username(resolve_username(params)?, "username")?;
1960 Ok(flatpak::ubuntu_list_flatpaks(&username))
1961 }
1962
1963 "Fail2banStatus" => {
1965 let jail = params
1966 .get("jail")
1967 .and_then(|v| v.as_str())
1968 .filter(|s| !s.is_empty())
1969 .map(|s| validated_safe_arg(s, "jail"))
1970 .transpose()?;
1971 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1972 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1973 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1974 })
1975 }
1976 "Fail2banBanIp" => {
1977 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1978 let ip = require_str(params, "ip")?;
1979 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1980 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1981 param: "ip",
1982 value: v,
1983 },
1984 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1985 })
1986 }
1987 "Fail2banUnbanIp" => {
1988 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1989 let ip = require_str(params, "ip")?;
1990 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1991 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1992 param: "ip",
1993 value: v,
1994 },
1995 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1996 })
1997 }
1998 "ConfigureFail2banJail" => {
1999 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
2000 let mut extra = Vec::new();
2001 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
2002 extra.push("--enabled".to_string());
2003 extra.push(if n { "true" } else { "false" }.to_string());
2004 }
2005 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
2006 if !(1..=100).contains(&n) {
2007 return Err(ExecutorError::InvalidParam("maxretry"));
2008 }
2009 extra.push("--maxretry".to_string());
2010 extra.push(n.to_string());
2011 }
2012 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
2013 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
2014 if n > MAX_FAIL2BAN_WINDOW_SECS {
2015 return Err(ExecutorError::InvalidParam(key));
2016 }
2017 extra.push(flag.to_string());
2018 extra.push(n.to_string());
2019 }
2020 }
2021 if extra.is_empty() {
2022 return Err(ExecutorError::MissingParam("maxretry"));
2023 }
2024 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
2025 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
2026 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
2027 })
2028 }
2029
2030 "GetAuditRules" => Ok(auditd::get_audit_rules()),
2032 "AddAuditRule" => {
2033 let path = validated_audit_path(require_str(params, "path")?, "path")?;
2034 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
2035 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2036 Ok(auditd::add_audit_rule(&path, &perms, &key))
2037 }
2038 "RemoveAuditRule" => {
2039 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
2040 Ok(auditd::remove_audit_rule(&key))
2041 }
2042
2043 "GetCertificates" => Ok(certbot::get_certificates()),
2045 "ObtainCertificate" => {
2046 let mut domains = Vec::new();
2048 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
2049 for d in arr {
2050 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
2051 domains.push(validated_domain(s, "domains")?);
2052 }
2053 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
2054 domains.push(validated_domain(d, "domain")?);
2055 }
2056 if domains.is_empty() {
2057 return Err(ExecutorError::MissingParam("domains"));
2058 }
2059 let email = validated_email(require_str(params, "email")?, "email")?;
2060 let challenge = params
2061 .get("challenge")
2062 .and_then(|v| v.as_str())
2063 .unwrap_or("standalone");
2064 if !matches!(challenge, "standalone" | "nginx" | "apache") {
2065 return Err(ExecutorError::InvalidParam("challenge"));
2066 }
2067 Ok(certbot::obtain_certificate(&domains, &email, challenge))
2068 }
2069 "RenewCertificates" => Ok(certbot::renew_certificates()),
2070
2071 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
2072 }
2073}
2074
2075pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
2081 match &spec.mechanism {
2082 ActionMechanism::Command { program, args } => {
2083 let mut child = tokio::process::Command::new(program)
2087 .args(args)
2088 .stdin(Stdio::null())
2089 .process_group(0)
2092 .stdout(Stdio::piped())
2093 .stderr(Stdio::piped())
2094 .spawn()?;
2095
2096 let stdout_h = child.stdout.take().expect("stdout was piped");
2101 let stderr_h = child.stderr.take().expect("stderr was piped");
2102 let stdout_task = tokio::spawn(async move {
2103 let mut buf = Vec::new();
2104 BufReader::new(stdout_h)
2105 .read_to_end(&mut buf)
2106 .await
2107 .map(|_| buf)
2108 });
2109 let stderr_task = tokio::spawn(async move {
2110 let mut buf = Vec::new();
2111 BufReader::new(stderr_h)
2112 .read_to_end(&mut buf)
2113 .await
2114 .map(|_| buf)
2115 });
2116
2117 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
2118 Ok(Ok(status)) => status,
2119 Ok(Err(err)) => {
2123 return Err(kill_and_reap(
2124 &mut child,
2125 program,
2126 args,
2127 StopCause::Unreadable(err),
2128 )
2129 .await)
2130 }
2131 Err(_) => {
2132 return Err(kill_and_reap(&mut child, program, args, StopCause::Timeout).await)
2133 }
2134 };
2135 let join =
2136 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
2137 let stdout = stdout_task.await.map_err(join)??;
2138 let stderr = stderr_task.await.map_err(join)??;
2139
2140 Ok(ExecutionOutput {
2141 stdout: String::from_utf8_lossy(&stdout).into_owned(),
2142 stderr: String::from_utf8_lossy(&stderr).into_owned(),
2143 exit_code: status.code().unwrap_or(-1),
2144 })
2145 }
2146 ActionMechanism::FileScan { path } => {
2147 let mut entries = tokio::fs::read_dir(path).await?;
2148 let mut names = Vec::new();
2149 while let Some(entry) = entries.next_entry().await? {
2150 names.push(entry.file_name().to_string_lossy().into_owned());
2151 }
2152 names.sort();
2153 Ok(ExecutionOutput {
2154 stdout: names.join("\n"),
2155 stderr: String::new(),
2156 exit_code: 0,
2157 })
2158 }
2159 ActionMechanism::FileWrite { path, content } => {
2160 if let Some(parent) = std::path::Path::new(path).parent() {
2161 tokio::fs::create_dir_all(parent).await?;
2162 }
2163 tokio::fs::write(path, content).await?;
2164 Ok(ExecutionOutput {
2165 stdout: String::new(),
2166 stderr: String::new(),
2167 exit_code: 0,
2168 })
2169 }
2170 ActionMechanism::FilePatch {
2171 path,
2172 search,
2173 replace,
2174 } => {
2175 let content = tokio::fs::read_to_string(path).await?;
2176 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
2177 if patched == content && !search.is_empty() {
2178 return Ok(ExecutionOutput {
2179 stdout: String::new(),
2180 stderr: format!("search string not found in file: {}", path),
2181 exit_code: 1,
2182 });
2183 }
2184 tokio::fs::write(path, patched).await?;
2185 Ok(ExecutionOutput {
2186 stdout: String::new(),
2187 stderr: String::new(),
2188 exit_code: 0,
2189 })
2190 }
2191 ActionMechanism::FileDelete { path } => {
2192 tokio::fs::remove_file(path).await?;
2193 Ok(ExecutionOutput {
2194 stdout: String::new(),
2195 stderr: String::new(),
2196 exit_code: 0,
2197 })
2198 }
2199 }
2200}
2201
2202fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
2203 match params.get(key) {
2204 None => Err(ExecutorError::MissingParam(key)),
2205 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
2206 }
2207}
2208
2209fn optional_validated<F>(
2213 params: &Value,
2214 key: &'static str,
2215 validator: F,
2216) -> Result<Option<String>, ExecutorError>
2217where
2218 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
2219{
2220 match params
2221 .get(key)
2222 .and_then(|v| v.as_str())
2223 .filter(|s| !s.is_empty())
2224 {
2225 Some(s) => Ok(Some(validator(s, key)?)),
2226 None => Ok(None),
2227 }
2228}
2229
2230fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
2240 params
2241 .get("username")
2242 .or_else(|| params.get("user"))
2243 .ok_or(ExecutorError::MissingParam("username"))
2244 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
2245}
2246
2247fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
2251 let id = require_str(params, "repo_id")?;
2252 let valid = !id.is_empty()
2253 && id
2254 .chars()
2255 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
2256 if valid {
2257 Ok(id)
2258 } else {
2259 Err(ExecutorError::InvalidParam("repo_id"))
2260 }
2261}
2262
2263fn validated_no_newline<'a>(
2266 params: &'a Value,
2267 key: &'static str,
2268) -> Result<&'a str, ExecutorError> {
2269 let val = require_str(params, key)?;
2270 if val.contains('\n') || val.contains('\r') {
2271 Err(ExecutorError::InvalidParam(key))
2272 } else {
2273 Ok(val)
2274 }
2275}
2276
2277fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
2281 const MAX_LEN: usize = 8192;
2282 const ALLOWED_PREFIXES: &[&str] = &[
2283 "ssh-rsa",
2284 "ssh-ed25519",
2285 "ssh-ed25519-sk",
2286 "ecdsa-sha2-nistp256",
2287 "ecdsa-sha2-nistp384",
2288 "ecdsa-sha2-nistp521",
2289 "sk-ssh-ed25519",
2290 "sk-ecdsa-sha2-nistp256",
2291 ];
2292
2293 if s.is_empty() || s.len() > MAX_LEN {
2294 return Err(ExecutorError::InvalidParam("public_key"));
2295 }
2296 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
2297 return Err(ExecutorError::InvalidParam("public_key"));
2298 }
2299 if s.chars().any(|c| {
2314 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
2315 || !c.is_ascii()
2316 || c.is_ascii_control()
2317 }) {
2318 return Err(ExecutorError::InvalidParam("public_key"));
2319 }
2320 Ok(s.to_string())
2321}
2322
2323fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
2324 match params.get(key) {
2325 None => Err(ExecutorError::MissingParam(key)),
2326 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
2327 }
2328}
2329
2330fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2331 match params.get(key) {
2332 None => Err(ExecutorError::MissingParam(key)),
2333 Some(v) => {
2334 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
2335 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
2336 }
2337 }
2338}
2339
2340fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
2344 let n = require_u32(params, key)?;
2345 if n == 0 {
2346 return Err(ExecutorError::InvalidParam(key));
2347 }
2348 Ok(n)
2349}
2350
2351fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
2355 match params.get(key) {
2356 None | Some(Value::Null) => Ok(vec![]),
2357 Some(Value::Array(arr)) => arr
2358 .iter()
2359 .map(|v| {
2360 v.as_str()
2361 .map(String::from)
2362 .ok_or(ExecutorError::InvalidParam(key))
2363 })
2364 .collect(),
2365 _ => Err(ExecutorError::InvalidParam(key)),
2366 }
2367}
2368
2369fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2404 const BLOCKED_PREFIXES: &[&str] = &[
2405 "init=",
2406 "selinux=0",
2407 "enforcing=0",
2408 "security=",
2409 "module_blacklist=",
2410 "apparmor=0",
2411 "mitigations=off",
2412 "lockdown=",
2413 "pti=off",
2414 "systemd.debug_shell",
2423 "systemd.debug-shell",
2424 "rd.systemd.debug_shell",
2425 "rd.systemd.debug-shell",
2426 ];
2427 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2428 use crate::actions::validate::ROOT_SHELL_UNITS as BLOCKED_UNIT_PREFIXES;
2434
2435 let lower = arg.to_lowercase();
2436 let base = lower.split('=').next().unwrap_or(&lower);
2438
2439 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2440 return Err(ExecutorError::InvalidParam(param));
2441 }
2442 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2443 return Err(ExecutorError::InvalidParam(param));
2444 }
2445 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2447 if BLOCKED_UNIT_PREFIXES
2448 .iter()
2449 .any(|u| unit_val.starts_with(u))
2450 {
2451 return Err(ExecutorError::InvalidParam(param));
2452 }
2453 }
2454 if BLOCKED_EXACT.contains(&base) {
2456 return Err(ExecutorError::InvalidParam(param));
2457 }
2458 Ok(())
2459}
2460
2461fn validated_safe_kernel_arg_removal(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
2477 const WEAKENING: &[&str] = &[
2480 "selinux=0",
2481 "enforcing=0",
2482 "apparmor=0",
2483 "mitigations=off",
2484 "pti=off",
2485 "lockdown=none",
2486 "debugfs=on",
2487 "iommu=off",
2488 "intel_iommu=off",
2489 "module.sig_enforce=0",
2490 "init_on_alloc=0",
2491 "init_on_free=0",
2492 "randomize_kstack_offset=off",
2493 "page_alloc.shuffle=0",
2494 ];
2495 const PROTECTIVE_PREFIXES: &[&str] = &[
2496 "lockdown=",
2497 "module.sig_enforce=",
2498 "security=",
2499 "selinux=",
2500 "enforcing=",
2501 "apparmor=",
2502 "pti=",
2503 "mitigations=",
2504 "init_on_alloc=",
2505 "init_on_free=",
2506 "page_alloc.shuffle=",
2507 "randomize_kstack_offset=",
2508 "slub_debug=",
2509 "page_poison=",
2510 "vsyscall=",
2511 "debugfs=",
2512 "iommu=",
2513 "intel_iommu=",
2514 "amd_iommu=",
2515 ];
2516 const PROTECTIVE_EXACT: &[&str] = &["slab_nomerge", "nosmt", "kaslr"];
2517
2518 let lower = arg.to_lowercase();
2519 if WEAKENING.contains(&lower.as_str()) {
2520 return Ok(());
2521 }
2522 if PROTECTIVE_PREFIXES.iter().any(|p| lower.starts_with(p))
2525 || PROTECTIVE_EXACT.contains(&lower.as_str())
2526 {
2527 return Err(ExecutorError::InvalidParam(param));
2528 }
2529 Ok(())
2533}
2534
2535fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2543 let normalized = s.trim().to_ascii_uppercase();
2544 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2545 "TERM" => Ok("TERM"),
2546 "KILL" => Ok("KILL"),
2547 "HUP" => Ok("HUP"),
2548 "INT" => Ok("INT"),
2549 _ => Err(ExecutorError::InvalidParam("signal")),
2550 }
2551}
2552
2553pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2563 match action_name {
2564 "UpdateSystem"
2565 | "InstallPackages"
2566 | "RemovePackages"
2567 | "RebaseSystem"
2568 | "SetKernelArguments"
2569 | "AddLayeredPackage"
2570 | "RemoveLayeredPackage"
2571 | "ReplaceLayeredPackage"
2572 | "ResetLayeredPackageOverride"
2573 | "RemoveBasePackage" => Some(ActionSpec {
2574 action_name: "RollbackDeployment",
2575 mechanism: ActionMechanism::Command {
2576 program: "rpm-ostree",
2577 args: vec!["rollback".to_string()],
2578 },
2579 risk_level: sysknife_types::RiskLevel::High,
2580 reboot_required: true,
2581 rollback_available: false,
2582 }),
2583 _ => None,
2584 }
2585}
2586
2587#[cfg(test)]
2588mod tests {
2589 use super::*;
2590 use serde_json::json;
2591 use sysknife_types::RiskLevel;
2592 use tempfile::tempdir;
2593
2594 #[test]
2597 fn build_spec_rejects_unknown_keys_across_catalogue() {
2598 for name in sysknife_types::KNOWN_ACTION_NAMES {
2599 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2600 continue;
2601 }
2602 assert!(
2603 matches!(
2604 build_action_spec(name, &json!({"__unknown_param": 1})),
2605 Err(ExecutorError::InvalidParam(_))
2606 ),
2607 "{name} must reject unknown keys before reading required params"
2608 );
2609 }
2610 }
2611
2612 #[test]
2613 fn build_spec_rejects_non_object_params_across_catalogue() {
2614 for name in sysknife_types::KNOWN_ACTION_NAMES {
2615 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2616 continue;
2617 }
2618 for params in [json!(null), json!([]), json!(42), json!(true), json!("{}")] {
2619 assert!(
2620 matches!(
2621 build_action_spec(name, ¶ms),
2622 Err(ExecutorError::InvalidParam(_))
2623 ),
2624 "{name} must reject non-object params: {params}"
2625 );
2626 }
2627 }
2628 }
2629
2630 #[test]
2631 fn build_spec_rejects_cross_distro_kernel_argument_key() {
2632 assert!(matches!(
2633 build_action_spec("SetKernelArguments", &json!({"append": ["quiet"]})),
2634 Err(ExecutorError::InvalidParam(_))
2635 ));
2636 assert!(matches!(
2637 build_action_spec("UfwEnable", &json!({"x": 1})),
2638 Err(ExecutorError::InvalidParam(_))
2639 ));
2640 }
2641
2642 #[test]
2643 fn build_spec_accepts_catalogue_params_and_username_aliases() {
2644 for name in sysknife_types::KNOWN_ACTION_NAMES {
2645 if crate::actions::DISPATCHER_INTERNAL_ACTIONS.contains(name) {
2646 continue;
2647 }
2648 let params = match *name {
2651 "PinDeployment" | "UnpinDeployment" => json!({"index": 0}),
2652 "RebaseSystem" => json!({"target_ref": "fedora-stable"}),
2653 "SetKernelArguments" => json!({"add": ["quiet"], "remove": ["splash"]}),
2654 "ListFlatpakRemotes"
2655 | "ListInstalledFlatpaks"
2656 | "ListContainers"
2657 | "ListToolboxes"
2658 | "DeleteUser"
2659 | "LockUserAccount"
2660 | "UnlockUserAccount"
2661 | "GetAuthorizedKeys"
2662 | "UbuntuListFlatpaks" => json!({"username": "alice"}),
2663 "InstallFlatpak" | "UbuntuInstallFlatpak" => {
2664 json!({"username": "alice", "app_id": "org.mozilla.firefox", "remote": "flathub"})
2665 }
2666 "RemoveFlatpak"
2667 | "GetFlatpakAppInfo"
2668 | "UbuntuRemoveFlatpak"
2669 | "UpdateFlatpak"
2670 | "UbuntuUpdateFlatpak" => {
2671 json!({"username": "alice", "app_id": "org.mozilla.firefox"})
2672 }
2673 "SearchFlatpakApps" | "AptSearch" => json!({"term": "vim"}),
2674 "AddFlatpakRemote" => {
2675 json!({"username": "alice", "remote": "flathub", "url": "https://flathub.org/repo/flathub.flatpakrepo"})
2676 }
2677 "RemoveFlatpakRemote" => json!({"username": "alice", "remote": "flathub"}),
2678 "CreateContainer" => {
2679 json!({"username": "alice", "name": "test", "image": "alpine"})
2680 }
2681 "StartContainer" | "StopContainer" | "RemoveContainer" | "GetContainerInfo"
2682 | "RemoveToolbox" => json!({"username": "alice", "name": "test"}),
2683 "InstallPackages" | "RemovePackages" => json!({"packages": ["vim"]}),
2684 "AddLayeredPackage"
2685 | "RemoveLayeredPackage"
2686 | "RemoveBasePackage"
2687 | "AptInstall"
2688 | "AptRemove"
2689 | "AptPurge"
2690 | "AptHold"
2691 | "AptUnhold"
2692 | "AptShow"
2693 | "GetAptPins" => json!({"package": "vim"}),
2694 "ReplaceLayeredPackage" => json!({"old": "vim", "new": "nano"}),
2695 "AddPackageRepository" => {
2696 json!({"repo_id": "test", "repo_url": "https://example.com/repo"})
2697 }
2698 "RemovePackageRepository"
2699 | "EnablePackageRepository"
2700 | "DisablePackageRepository" => json!({"repo_id": "test"}),
2701 "StartService"
2702 | "StopService"
2703 | "RestartService"
2704 | "MaskService"
2705 | "UnmaskService"
2706 | "GetServiceLogs"
2707 | "GetServiceStatus"
2708 | "ReloadService"
2709 | "GetServiceResourceLimits" => json!({"unit": "nginx.service"}),
2710 "SetServiceEnabled" => json!({"unit": "nginx.service", "enabled": true}),
2711 "CreateScheduledJob" => {
2712 json!({"name": "test", "command": "/usr/bin/true", "schedule": "daily"})
2713 }
2714 "SetServiceResourceLimits" => {
2715 json!({"unit": "nginx.service", "memory_max": "1G", "memory_high": "512M", "cpu_quota": "50%", "tasks_max": "100"})
2716 }
2717 "CreateToolbox" => {
2718 json!({"username": "alice", "name": "test", "image": "fedora-toolbox", "release": "41"})
2719 }
2720 "SetHostname" => json!({"hostname": "testhost"}),
2721 "SetTimezone" => json!({"timezone": "Europe/London"}),
2722 "SetLocale" => json!({"locale": "en_US.UTF-8"}),
2723 "SetNtp" | "ConfigureUnattendedUpgrades" => json!({"enabled": true}),
2724 "SignalProcess" => json!({"pid": 42, "signal": "TERM"}),
2725 "GetJournalLog" => {
2726 json!({"unit": "nginx.service", "priority": "warning", "since": "yesterday", "until": "now", "grep": "error", "lines": 10, "boot": true, "kernel": false})
2727 }
2728 "VacuumJournal" => json!({"size_mb": 10}),
2729 "ExtendLogicalVolume" => json!({"vg": "vg0", "lv": "data", "size": "+1G"}),
2730 "CreateLogicalVolume" => json!({"vg": "vg0", "name": "data", "size": "1G"}),
2731 "CreateLvSnapshot" => {
2732 json!({"vg": "vg0", "origin": "data", "snapshot": "backup", "size": "1G"})
2733 }
2734 "GetSysctl" => json!({"key": "net.ipv4.ip_forward"}),
2735 "SetSysctl" => json!({"key": "net.ipv4.ip_forward", "value": "1"}),
2736 "AddMount" => {
2737 json!({"device": "/dev/sdb1", "mountpoint": "/mnt/data", "fstype": "ext4", "options": "defaults"})
2738 }
2739 "RemoveMount" => json!({"mountpoint": "/mnt/data"}),
2740 "AddSwap" => json!({"file": "/swapfile", "size_mb": 1024}),
2741 "RemoveSwap" => json!({"file": "/swapfile"}),
2742 "GrantSudoAccess" => {
2743 json!({"name": "test", "user": "alice", "commands": "/usr/bin/uptime", "runas": "root", "nopasswd": true})
2744 }
2745 "RevokeSudoAccess" | "RemoveAptPin" | "RemoveLogRotation" => {
2746 json!({"name": "test"})
2747 }
2748 "SetAptPin" => {
2749 json!({"name": "test", "package": "vim", "pin": "version 1.*", "priority": 500})
2750 }
2751 "GetLogrotateStatus" => json!({"config": "/var/log/app.log"}),
2752 "ConfigureLogRotation" => {
2753 json!({"name": "test", "path": "/var/log/app.log", "frequency": "daily", "rotate": 7, "compress": true})
2754 }
2755 "ConfigureRemoteSyslog" => {
2756 json!({"host": "logs.example.com", "port": 514, "protocol": "tcp"})
2757 }
2758 "GetPasswordAging" => json!({"user": "alice"}),
2759 "SetPasswordAging" => {
2760 json!({"user": "alice", "max_days": 90, "min_days": 1, "warn_days": 7})
2761 }
2762 "SetPasswordPolicy" => {
2763 json!({"minlen": 12, "dcredit": -1, "ucredit": -1, "lcredit": -1, "ocredit": -1})
2764 }
2765 "SetAccountLockout" => json!({"deny": 5, "unlock_time": 600, "fail_interval": 900}),
2766 "ConfigureWifi" => json!({"ssid": "testwifi", "password": "examplepassword"}),
2767 "SetDnsServers" | "ResolvectlSetDns" => {
2768 json!({"interface": "eth0", "servers": ["1.1.1.1"]})
2769 }
2770 "ConfigureFirewall" => json!({"zone": "public", "service": "ssh", "enabled": true}),
2771 "CreateUser" => {
2772 json!({"username": "alice", "shell": "/bin/bash", "home": "/home/alice"})
2773 }
2774 "AddUserToGroup" | "RemoveUserFromGroup" => {
2775 json!({"username": "alice", "group": "developers"})
2776 }
2777 "CreateGroup" => json!({"group": "developers", "system": true}),
2778 "DeleteGroup" => json!({"group": "developers"}),
2779 "AddAuthorizedKey" | "RemoveAuthorizedKey" => {
2780 json!({"username": "alice", "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAITestKey alice"})
2781 }
2782 "SetSshdOption" => json!({"option": "PasswordAuthentication", "value": "no"}),
2783 "AddPpa" | "RemovePpa" => json!({"name": "deadsnakes/ppa"}),
2784 "SnapInstall" => json!({"name": "test", "channel": "stable", "auto_update": true}),
2785 "SnapRemove" | "SnapRefresh" | "SnapHold" | "SnapUnhold" | "SnapInfo"
2786 | "SnapRevert" | "SnapClassicInstall" | "DistroboxRemove" => {
2787 json!({"name": "test"})
2788 }
2789 "GrubSetKargs" => json!({"append": ["quiet"], "delete": ["splash"]}),
2790 "UfwAllow" | "UfwDeny" => json!({"port_or_service": "22/tcp"}),
2791 "UfwStatus" => json!({"numbered": true}),
2792 "DistroboxCreate" => json!({"name": "test", "image": "ubuntu"}),
2793 "NetplanSet" => json!({"key": "ethernets.eth0.dhcp4", "value": "true"}),
2794 "UfwDeleteRule" => json!({"rule_number": 1}),
2795 "UfwLimit" => json!({"target": "ssh"}),
2796 "ProAttach" => json!({"token": "exampletoken"}),
2797 "EnableProService" | "DisableProService" => json!({"service": "esm-apps"}),
2798 "AppArmorEnforce" | "AppArmorComplain" => {
2799 json!({"profile_path": "/etc/apparmor.d/usr.bin.test"})
2800 }
2801 "Fail2banStatus" => json!({"jail": "sshd"}),
2802 "Fail2banBanIp" | "Fail2banUnbanIp" => json!({"jail": "sshd", "ip": "192.0.2.1"}),
2803 "ConfigureFail2banJail" => {
2804 json!({"name": "sshd", "enabled": true, "maxretry": 5, "bantime": 600, "findtime": 900})
2805 }
2806 "AddAuditRule" => json!({"path": "/etc/passwd", "perms": "wa", "key": "identity"}),
2807 "RemoveAuditRule" => json!({"key": "identity"}),
2808 "ObtainCertificate" => {
2809 json!({"domains": ["example.com"], "domain": "example.com", "email": "admin@example.com", "challenge": "standalone"})
2810 }
2811 _ => json!({}),
2812 };
2813 build_action_spec(name, ¶ms)
2814 .unwrap_or_else(|err| panic!("valid parameters rejected for {name}: {err}"));
2815 if params.get("username").is_some() {
2816 let mut aliased = params;
2817 let username = aliased.as_object_mut().unwrap().remove("username").unwrap();
2818 aliased["user"] = username;
2819 build_action_spec(name, &aliased)
2820 .unwrap_or_else(|err| panic!("user alias rejected for {name}: {err}"));
2821 }
2822 }
2823 for params in [json!({"retain_days": 7}), json!({"size_mb": 10})] {
2824 build_action_spec("VacuumJournal", ¶ms).unwrap();
2825 }
2826 build_action_spec(
2827 "ObtainCertificate",
2828 &json!({"domain": "example.com", "email": "admin@example.com"}),
2829 )
2830 .unwrap();
2831 build_action_spec("SignalProcess", &json!({"pid": "42"})).unwrap();
2832 build_action_spec("SetKernelArguments", &json!({"add": null, "remove": null})).unwrap();
2833 }
2834
2835 #[test]
2836 fn build_spec_no_params_for_get_system_state() {
2837 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2838 assert_eq!(spec.action_name, "GetSystemState");
2839 assert_eq!(spec.risk_level, RiskLevel::Low);
2840 }
2841
2842 #[test]
2843 fn build_spec_get_datetime_is_low_risk() {
2844 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2845 assert_eq!(spec.action_name, "GetDateTime");
2846 assert_eq!(spec.risk_level, RiskLevel::Low);
2847 assert!(!spec.reboot_required);
2848 }
2849
2850 #[test]
2851 fn build_spec_unknown_action_returns_error() {
2852 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2853 assert!(
2854 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2855 "expected UnknownAction, got: {err}"
2856 );
2857 }
2858
2859 #[test]
2860 fn build_spec_missing_param_for_install_flatpak() {
2861 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2863 assert!(
2864 matches!(err, ExecutorError::MissingParam("username")),
2865 "expected MissingParam(username), got: {err}"
2866 );
2867 }
2868
2869 #[test]
2873 fn build_spec_flatpak_accepts_user_alias() {
2874 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2875 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2876 }
2877
2878 #[test]
2880 fn build_spec_resolve_username_prefers_explicit_username() {
2881 let spec = build_action_spec(
2882 "ListInstalledFlatpaks",
2883 &json!({ "username": "alice", "user": "bob" }),
2884 )
2885 .unwrap();
2886 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2888 }
2889
2890 #[test]
2893 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2894 let spec = build_action_spec(
2895 "InstallFlatpak",
2896 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2897 )
2898 .unwrap();
2899 assert_eq!(
2900 spec.mechanism,
2901 ActionMechanism::Command {
2902 program: "sudo",
2903 args: vec![
2904 "/usr/lib/sysknife/action-steps".to_string(),
2905 "flatpak".to_string(),
2906 "alice".to_string(),
2907 "install".to_string(),
2908 "--user".to_string(),
2909 "-y".to_string(),
2910 "flathub".to_string(),
2911 "org.mozilla.firefox".to_string(),
2912 ],
2913 }
2914 );
2915 }
2916
2917 #[test]
2918 fn build_spec_install_flatpak_injects_app_and_remote() {
2919 let spec = build_action_spec(
2920 "InstallFlatpak",
2921 &json!({
2922 "username": "alice",
2923 "app_id": "org.mozilla.firefox",
2924 "remote": "flathub"
2925 }),
2926 )
2927 .unwrap();
2928 assert_eq!(spec.action_name, "InstallFlatpak");
2929 assert_eq!(
2930 spec.mechanism,
2931 ActionMechanism::Command {
2932 program: "sudo",
2933 args: vec![
2934 "/usr/lib/sysknife/action-steps".to_string(),
2935 "flatpak".to_string(),
2936 "alice".to_string(),
2937 "install".to_string(),
2938 "--user".to_string(),
2939 "-y".to_string(),
2940 "flathub".to_string(),
2941 "org.mozilla.firefox".to_string(),
2942 ],
2943 }
2944 );
2945 }
2946
2947 #[test]
2948 fn build_spec_pin_deployment_injects_index() {
2949 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2950 assert_eq!(
2951 spec.mechanism,
2952 ActionMechanism::Command {
2953 program: "sudo",
2954 args: vec![
2955 "ostree".to_string(),
2956 "admin".to_string(),
2957 "pin".to_string(),
2958 "1".to_string()
2959 ],
2960 }
2961 );
2962 }
2963
2964 #[test]
2965 fn build_spec_unpin_deployment_includes_unpin_flag() {
2966 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2967 assert_eq!(
2968 spec.mechanism,
2969 ActionMechanism::Command {
2970 program: "sudo",
2971 args: vec![
2972 "ostree".to_string(),
2973 "admin".to_string(),
2974 "pin".to_string(),
2975 "--unpin".to_string(),
2976 "2".to_string(),
2977 ],
2978 }
2979 );
2980 }
2981
2982 #[test]
2983 fn require_u32_rejects_overflow() {
2984 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2985 assert!(
2986 matches!(err, ExecutorError::InvalidParam("index")),
2987 "expected InvalidParam(index), got: {err}"
2988 );
2989 }
2990
2991 #[test]
2992 fn build_spec_rebase_system_injects_target_ref() {
2993 let spec = build_action_spec(
2994 "RebaseSystem",
2995 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
2996 )
2997 .unwrap();
2998 assert_eq!(
2999 spec.mechanism,
3000 ActionMechanism::Command {
3001 program: "sudo",
3002 args: vec![
3003 "rpm-ostree".to_string(),
3004 "rebase".to_string(),
3005 "fedora/41/x86_64/silverblue".to_string(),
3006 ],
3007 }
3008 );
3009 }
3010
3011 #[test]
3012 fn build_spec_set_kernel_arguments_appends_and_deletes() {
3013 let spec = build_action_spec(
3014 "SetKernelArguments",
3015 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
3016 )
3017 .unwrap();
3018 assert_eq!(
3019 spec.mechanism,
3020 ActionMechanism::Command {
3021 program: "sudo",
3022 args: vec![
3023 "rpm-ostree".to_string(),
3024 "kargs".to_string(),
3025 "--append=nomodeset".to_string(),
3026 "--delete=quiet".to_string(),
3027 ],
3028 }
3029 );
3030 }
3031
3032 #[test]
3033 fn build_spec_set_kernel_arguments_with_empty_arrays() {
3034 let spec =
3035 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
3036 assert_eq!(
3037 spec.mechanism,
3038 ActionMechanism::Command {
3039 program: "sudo",
3040 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
3041 }
3042 );
3043 }
3044
3045 #[test]
3046 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
3047 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
3048 assert_eq!(
3049 spec.mechanism,
3050 ActionMechanism::Command {
3051 program: "sudo",
3052 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
3053 }
3054 );
3055 }
3056
3057 #[test]
3060 fn delete_user_rejects_critical_account() {
3061 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
3062 assert!(
3063 matches!(err, ExecutorError::InvalidParam("username")),
3064 "expected InvalidParam(username), got: {err}"
3065 );
3066 }
3067
3068 #[test]
3069 fn delete_group_rejects_critical_group() {
3070 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
3071 assert!(
3072 matches!(err, ExecutorError::InvalidParam("group")),
3073 "expected InvalidParam(group), got: {err}"
3074 );
3075 }
3076
3077 #[test]
3078 fn lock_user_account_rejects_critical_account() {
3079 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
3080 assert!(
3081 matches!(err, ExecutorError::InvalidParam("username")),
3082 "expected InvalidParam(username), got: {err}"
3083 );
3084 }
3085
3086 #[test]
3087 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
3088 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
3089 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
3090 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
3091 }
3092
3093 #[test]
3096 fn build_spec_add_package_repository_rejects_path_traversal() {
3097 let err = build_action_spec(
3098 "AddPackageRepository",
3099 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
3100 )
3101 .unwrap_err();
3102 assert!(
3103 matches!(err, ExecutorError::InvalidParam("repo_id")),
3104 "expected InvalidParam(repo_id), got: {err}"
3105 );
3106 }
3107
3108 #[test]
3109 fn build_spec_add_package_repository_rejects_newline_in_url() {
3110 let err = build_action_spec(
3111 "AddPackageRepository",
3112 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
3113 )
3114 .unwrap_err();
3115 assert!(
3116 matches!(err, ExecutorError::InvalidParam("repo_url")),
3117 "expected InvalidParam(repo_url), got: {err}"
3118 );
3119 }
3120
3121 #[test]
3122 fn build_spec_add_package_repository_accepts_valid_repo_id() {
3123 let spec = build_action_spec(
3124 "AddPackageRepository",
3125 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
3126 )
3127 .unwrap();
3128 assert_eq!(spec.action_name, "AddPackageRepository");
3129 }
3130
3131 #[test]
3132 fn build_spec_remove_package_repository_rejects_path_traversal() {
3133 let err = build_action_spec(
3134 "RemovePackageRepository",
3135 &json!({ "repo_id": "../../etc/passwd" }),
3136 )
3137 .unwrap_err();
3138 assert!(
3139 matches!(err, ExecutorError::InvalidParam("repo_id")),
3140 "expected InvalidParam(repo_id), got: {err}"
3141 );
3142 }
3143
3144 #[tokio::test]
3145 async fn execute_spec_command_captures_stdout() {
3146 let spec = ActionSpec {
3147 action_name: "GetSystemState",
3148 mechanism: ActionMechanism::Command {
3149 program: "echo",
3150 args: vec!["hello".to_string()],
3151 },
3152 risk_level: RiskLevel::Low,
3153 reboot_required: false,
3154 rollback_available: false,
3155 };
3156 let out = execute_spec(&spec).await.unwrap();
3157 assert_eq!(out.stdout.trim(), "hello");
3158 assert_eq!(out.exit_code, 0);
3159 }
3160
3161 #[test]
3169 fn action_not_stopped_names_the_group_and_the_timeout() {
3170 let err = ExecutorError::ActionNotStopped {
3171 program: "rpm-ostree".to_string(),
3172 pgid: 4242,
3173 timeout_secs: 7200,
3174 };
3175 let msg = err.to_string();
3176 assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
3177 assert!(msg.contains("7200"), "must name the timeout: {msg}");
3178 assert!(
3179 msg.contains("could not be confirmed stopped"),
3180 "must say what happened: {msg}"
3181 );
3182 assert!(
3183 msg.contains("rollback was skipped"),
3184 "must say the rollback was skipped: {msg}"
3185 );
3186 }
3187
3188 #[tokio::test]
3193 async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
3194 let mut child = tokio::process::Command::new("sh")
3195 .args(["-c", "sleep 3600 & wait"])
3196 .process_group(0)
3197 .stdin(Stdio::null())
3198 .stdout(Stdio::null())
3199 .stderr(Stdio::null())
3200 .spawn()
3201 .expect("spawn");
3202 SUDO_INVOCATIONS.store(0, std::sync::atomic::Ordering::SeqCst);
3203 let started = tokio::time::Instant::now();
3204 let err = kill_and_reap(&mut child, "sh", &[], StopCause::Timeout).await;
3205 let elapsed = started.elapsed();
3206
3207 assert!(
3208 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
3209 "a killable group must report a plain stopped-timeout, got: {err:?}"
3210 );
3211 assert!(
3212 elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
3213 "must return promptly, took {elapsed:?}"
3214 );
3215 assert_eq!(
3219 SUDO_INVOCATIONS.load(std::sync::atomic::Ordering::SeqCst),
3220 0,
3221 "a killable group must not escalate to the privileged reaper"
3222 );
3223 }
3224
3225 #[tokio::test]
3233 async fn a_child_that_outlives_a_stdout_read_failure_is_stopped_not_orphaned() {
3234 let dir = tempfile::tempdir().expect("tempdir");
3235 let pgid_file = dir.path().join("pgid");
3236 let script = format!(
3240 "echo $$ > {}; echo started; printf '\\377\\n'; sleep 3600",
3241 pgid_file.display()
3242 );
3243 let (tx, _rx) = tokio::sync::mpsc::unbounded_channel();
3244 let err = execute_command_with_progress("sh", &["-c".to_string(), script], tx)
3245 .await
3246 .expect_err("non-UTF-8 stdout must fail the action");
3247 assert!(
3248 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::InvalidData),
3249 "the read failure itself must be surfaced, got: {err:?}"
3250 );
3251
3252 let pgid: i32 = std::fs::read_to_string(&pgid_file)
3253 .expect("the shell must have recorded its pgid")
3254 .trim()
3255 .parse()
3256 .expect("pgid");
3257 assert!(
3258 probe_group(pgid).is_gone(),
3259 "process group {pgid} survived the read failure; \
3260 the privileged child was orphaned"
3261 );
3262 }
3263
3264 #[test]
3265 fn reap_command_cancels_rpm_ostree_but_group_kills_everything_else() {
3266 assert_eq!(
3268 reap_command(
3269 99,
3270 "sudo",
3271 &["/usr/bin/rpm-ostree".to_string(), "upgrade".to_string()]
3272 ),
3273 vec!["-n", "/usr/bin/rpm-ostree", "cancel"]
3274 );
3275 assert_eq!(
3277 reap_command(
3278 99,
3279 "sudo",
3280 &[
3281 "env".to_string(),
3282 "apt-get".to_string(),
3283 "install".to_string()
3284 ]
3285 ),
3286 root_group_kill_argv("TERM", 99)
3287 );
3288 }
3289
3290 #[test]
3291 fn root_group_kill_argv_targets_the_whole_group_non_interactively() {
3292 assert_eq!(
3295 root_group_kill_argv("KILL", 4242),
3296 vec!["-n", "/usr/bin/kill", "-s", "KILL", "--", "-4242"]
3297 );
3298 assert_eq!(
3299 root_group_kill_argv("TERM", 7)[3..].to_vec(),
3300 vec!["TERM", "--", "-7"]
3301 );
3302 }
3303
3304 #[test]
3305 fn is_rpm_ostree_action_detects_the_transaction_client_behind_sudo() {
3306 let sudo = |a: &[&str]| {
3307 is_rpm_ostree_action("sudo", &a.iter().map(|s| s.to_string()).collect::<Vec<_>>())
3308 };
3309 assert!(sudo(&["/usr/bin/rpm-ostree", "upgrade"]));
3311 assert!(sudo(&[
3312 "rpm-ostree",
3313 "rebase",
3314 "fedora:fedora/40/x86_64/silverblue"
3315 ]));
3316 assert!(is_rpm_ostree_action("rpm-ostree", &["status".to_string()]));
3317 assert!(!sudo(&[
3320 "env",
3321 "DEBIAN_FRONTEND=noninteractive",
3322 "apt-get",
3323 "install",
3324 "vim"
3325 ]));
3326 assert!(!sudo(&["/usr/bin/kill", "-s", "KILL", "--", "-1"]));
3327 assert!(!is_rpm_ostree_action("sudo", &[]));
3328 assert!(is_rpm_ostree_action(
3330 "/usr/bin/rpm-ostree",
3331 &["deploy".to_string()]
3332 ));
3333 assert!(!is_rpm_ostree_action(
3336 "sudo",
3337 &["explain-rpm-ostree-usage".to_string()]
3338 ));
3339 }
3340
3341 #[tokio::test]
3344 async fn real_executor_streams_each_stdout_line() {
3345 let spec = ActionSpec {
3346 action_name: "GetSystemState",
3347 mechanism: ActionMechanism::Command {
3348 program: "printf",
3349 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
3350 },
3351 risk_level: RiskLevel::Low,
3352 reboot_required: false,
3353 rollback_available: false,
3354 };
3355 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
3356 let out = RealActionExecutor
3357 .execute_with_progress(&spec, tx)
3358 .await
3359 .unwrap();
3360
3361 let mut lines = Vec::new();
3362 while let Ok(line) = rx.try_recv() {
3363 lines.push(line);
3364 }
3365 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
3366 assert_eq!(out.exit_code, 0);
3367 assert!(out.stdout.contains("line-two"));
3368 }
3369
3370 #[tokio::test]
3371 async fn execute_spec_file_write_creates_file() {
3372 let dir = tempdir().unwrap();
3373 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
3374 let spec = ActionSpec {
3375 action_name: "AddPackageRepository",
3376 mechanism: ActionMechanism::FileWrite {
3377 path: path.clone(),
3378 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
3379 },
3380 risk_level: RiskLevel::Medium,
3381 reboot_required: false,
3382 rollback_available: true,
3383 };
3384 let out = execute_spec(&spec).await.unwrap();
3385 assert_eq!(out.exit_code, 0);
3386 assert_eq!(
3387 std::fs::read_to_string(&path).unwrap(),
3388 "[repo]\nbaseurl=https://example.test\n"
3389 );
3390 }
3391
3392 #[tokio::test]
3393 async fn execute_spec_file_patch_replaces_first_occurrence() {
3394 let dir = tempdir().unwrap();
3395 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3396 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
3397 let spec = ActionSpec {
3398 action_name: "EnablePackageRepository",
3399 mechanism: ActionMechanism::FilePatch {
3400 path: path.clone(),
3401 search: "enabled=0".to_string(),
3402 replace: "enabled=1".to_string(),
3403 },
3404 risk_level: RiskLevel::Medium,
3405 reboot_required: false,
3406 rollback_available: true,
3407 };
3408 execute_spec(&spec).await.unwrap();
3409 assert_eq!(
3410 std::fs::read_to_string(&path).unwrap(),
3411 "[myrepo]\nenabled=1\n"
3412 );
3413 }
3414
3415 #[tokio::test]
3416 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
3417 let dir = tempdir().unwrap();
3418 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3419 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
3420 let spec = ActionSpec {
3421 action_name: "EnablePackageRepository",
3422 mechanism: ActionMechanism::FilePatch {
3423 path: path.clone(),
3424 search: "enabled=0".to_string(),
3425 replace: "enabled=1".to_string(),
3426 },
3427 risk_level: RiskLevel::Medium,
3428 reboot_required: false,
3429 rollback_available: true,
3430 };
3431 let out = execute_spec(&spec).await.unwrap();
3432 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
3433 assert!(
3434 out.stderr.contains("search string not found in file"),
3435 "stderr should explain the failure: {}",
3436 out.stderr
3437 );
3438 assert_eq!(
3440 std::fs::read_to_string(&path).unwrap(),
3441 "[myrepo]\nenabled=1\n"
3442 );
3443 }
3444
3445 #[tokio::test]
3446 async fn execute_spec_file_patch_allows_empty_search_string() {
3447 let dir = tempdir().unwrap();
3450 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
3451 std::fs::write(&path, "hello").unwrap();
3452 let spec = ActionSpec {
3453 action_name: "Test",
3454 mechanism: ActionMechanism::FilePatch {
3455 path: path.clone(),
3456 search: String::new(),
3457 replace: "prefix-".to_string(),
3458 },
3459 risk_level: RiskLevel::Low,
3460 reboot_required: false,
3461 rollback_available: false,
3462 };
3463 let out = execute_spec(&spec).await.unwrap();
3464 assert_eq!(out.exit_code, 0);
3465 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
3466 }
3467
3468 #[tokio::test]
3469 async fn execute_spec_file_delete_removes_file() {
3470 let dir = tempdir().unwrap();
3471 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
3472 std::fs::write(&path, "[myrepo]\n").unwrap();
3473 let spec = ActionSpec {
3474 action_name: "RemovePackageRepository",
3475 mechanism: ActionMechanism::FileDelete { path: path.clone() },
3476 risk_level: RiskLevel::Medium,
3477 reboot_required: false,
3478 rollback_available: true,
3479 };
3480 execute_spec(&spec).await.unwrap();
3481 assert!(!std::path::Path::new(&path).exists());
3482 }
3483
3484 #[tokio::test]
3485 async fn execute_spec_file_scan_lists_directory_entries() {
3486 let dir = tempdir().unwrap();
3487 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
3488 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
3489 let spec = ActionSpec {
3490 action_name: "ListPackageRepositories",
3491 mechanism: ActionMechanism::FileScan {
3492 path: dir.path().to_string_lossy().into_owned(),
3493 },
3494 risk_level: RiskLevel::Low,
3495 reboot_required: false,
3496 rollback_available: false,
3497 };
3498 let out = execute_spec(&spec).await.unwrap();
3499 assert!(
3500 out.stdout.contains("a.repo"),
3501 "expected a.repo in: {}",
3502 out.stdout
3503 );
3504 assert!(
3505 out.stdout.contains("b.repo"),
3506 "expected b.repo in: {}",
3507 out.stdout
3508 );
3509 assert_eq!(out.exit_code, 0);
3510 }
3511
3512 #[test]
3515 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
3516 let spec = rollback_spec_for("UpdateSystem").unwrap();
3517 assert_eq!(spec.action_name, "RollbackDeployment");
3518 assert!(
3519 matches!(
3520 &spec.mechanism,
3521 ActionMechanism::Command { program: "rpm-ostree", args }
3522 if args == &["rollback".to_string()]
3523 ),
3524 "expected rpm-ostree rollback, got: {:?}",
3525 spec.mechanism
3526 );
3527 assert!(!spec.rollback_available, "rollback spec must not recurse");
3528 }
3529
3530 #[test]
3531 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
3532 assert!(rollback_spec_for("InstallPackages").is_some());
3533 }
3534
3535 #[test]
3536 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
3537 assert!(rollback_spec_for("RemovePackages").is_some());
3538 }
3539
3540 #[test]
3541 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
3542 assert!(rollback_spec_for("RebaseSystem").is_some());
3543 }
3544
3545 #[test]
3546 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
3547 assert!(rollback_spec_for("SetKernelArguments").is_some());
3548 }
3549
3550 #[test]
3551 fn rollback_spec_for_read_only_action_returns_none() {
3552 assert!(rollback_spec_for("GetSystemState").is_none());
3553 assert!(rollback_spec_for("ListUsers").is_none());
3554 assert!(rollback_spec_for("GetFirewallState").is_none());
3555 }
3556
3557 #[test]
3558 fn rollback_spec_for_non_rollbackable_actions_return_none() {
3559 assert!(rollback_spec_for("AddUserToGroup").is_none());
3560 assert!(rollback_spec_for("DeleteUser").is_none());
3561 assert!(rollback_spec_for("CleanupDeployments").is_none());
3562 assert!(rollback_spec_for("RollbackDeployment").is_none());
3564 }
3565
3566 #[test]
3569 fn public_key_accepts_valid_ed25519() {
3570 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
3571 assert!(validated_public_key(key).is_ok());
3572 }
3573
3574 #[test]
3575 fn public_key_accepts_valid_rsa() {
3576 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
3577 assert!(validated_public_key(key).is_ok());
3578 }
3579
3580 #[test]
3581 fn public_key_rejects_empty() {
3582 assert!(matches!(
3583 validated_public_key(""),
3584 Err(ExecutorError::InvalidParam("public_key"))
3585 ));
3586 }
3587
3588 #[test]
3589 fn public_key_rejects_unknown_prefix() {
3590 assert!(matches!(
3591 validated_public_key("sk-rsa AAAA... user@host"),
3592 Err(ExecutorError::InvalidParam("public_key"))
3593 ));
3594 assert!(matches!(
3595 validated_public_key("AAAAB3Nz... user@host"),
3596 Err(ExecutorError::InvalidParam("public_key"))
3597 ));
3598 }
3599
3600 #[test]
3601 fn public_key_rejects_single_quote() {
3602 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
3603 assert!(matches!(
3604 validated_public_key(key),
3605 Err(ExecutorError::InvalidParam("public_key"))
3606 ));
3607 }
3608
3609 #[test]
3617 fn package_actions_reject_a_local_file_target() {
3618 let reject: &[(&str, serde_json::Value)] = &[
3619 ("AptInstall", json!({"package": "/tmp/evil.deb"})),
3620 ("AptRemove", json!({"package": "/tmp/evil.deb"})),
3621 ("AptPurge", json!({"package": "./evil.deb"})),
3622 ("AptHold", json!({"package": "/tmp/evil.deb"})),
3623 ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
3624 ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3625 ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
3626 ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
3627 (
3628 "ReplaceLayeredPackage",
3629 json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
3630 ),
3631 (
3632 "ReplaceLayeredPackage",
3633 json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
3634 ),
3635 ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
3636 ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
3637 ];
3638 for (action, params) in reject {
3639 assert!(
3640 matches!(
3641 build_action_spec(action, params),
3642 Err(ExecutorError::InvalidParam(_))
3643 ),
3644 "{action} must refuse a local-file package target: {params}"
3645 );
3646 }
3647 let accept: &[(&str, serde_json::Value)] = &[
3650 ("AptInstall", json!({"package": "nginx"})),
3651 ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
3652 (
3653 "InstallPackages",
3654 json!({"packages": ["nginx", "python3-pip"]}),
3655 ),
3656 ];
3657 for (action, params) in accept {
3658 assert!(
3659 build_action_spec(action, params).is_ok(),
3660 "{action} must accept a real package spec: {params}"
3661 );
3662 }
3663 }
3664
3665 #[test]
3670 fn activating_actions_refuse_a_root_shell_unit() {
3671 for action in ["StartService", "RestartService", "UnmaskService"] {
3672 for unit in [
3673 "debug-shell.service",
3674 "emergency.target",
3675 "rescue.service",
3676 "runlevel1.target",
3677 ] {
3678 assert!(
3679 matches!(
3680 build_action_spec(action, &json!({"unit": unit})),
3681 Err(ExecutorError::InvalidParam("unit"))
3682 ),
3683 "{action} must not activate {unit}"
3684 );
3685 }
3686 assert!(
3687 build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
3688 "{action} must still activate an ordinary unit"
3689 );
3690 }
3691 assert!(
3692 build_action_spec(
3693 "SetServiceEnabled",
3694 &json!({"unit": "debug-shell.service", "enabled": true})
3695 )
3696 .is_err(),
3697 "enabling a root-shell unit brings it up at boot and must be refused"
3698 );
3699 assert!(
3700 build_action_spec(
3701 "SetServiceEnabled",
3702 &json!({"unit": "debug-shell.service", "enabled": false})
3703 )
3704 .is_ok(),
3705 "disabling a root-shell unit is a mitigation and must stay reachable"
3706 );
3707 for action in ["StopService", "MaskService"] {
3710 assert!(
3711 build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
3712 "{action} on debug-shell is a mitigation and must stay allowed"
3713 );
3714 }
3715 }
3716
3717 #[test]
3718 fn authorized_keys_actions_reject_a_traversal_username() {
3719 for action in [
3726 "GetAuthorizedKeys",
3727 "AddAuthorizedKey",
3728 "RemoveAuthorizedKey",
3729 ] {
3730 for bad in ["../../etc", "..", "root/../../etc"] {
3731 let mut params = json!({"username": bad});
3732 if action != "GetAuthorizedKeys" {
3733 params["public_key"] =
3734 json!("ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h");
3735 }
3736 let err = build_action_spec(action, ¶ms).unwrap_err();
3737 assert!(
3738 matches!(err, ExecutorError::InvalidParam("username")),
3739 "{action} must reject username {bad:?}, got {err:?}"
3740 );
3741 }
3742 }
3743 }
3744
3745 #[test]
3746 fn ip_taking_actions_reject_a_malformed_address() {
3747 let cases: [(&str, serde_json::Value); 3] = [
3750 (
3751 "ResolvectlSetDns",
3752 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
3753 ),
3754 (
3755 "Fail2banBanIp",
3756 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
3757 ),
3758 (
3759 "Fail2banUnbanIp",
3760 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
3761 ),
3762 ];
3763 for (action, params) in cases {
3764 let err = build_action_spec(action, ¶ms).unwrap_err();
3765 assert!(
3766 matches!(err, ExecutorError::InvalidIpAddress { .. }),
3767 "{action} must reject a malformed IP, got {err:?}"
3768 );
3769 }
3770 }
3771
3772 #[test]
3773 fn public_key_rejects_pipe_metacharacter() {
3774 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
3778 assert!(matches!(
3779 validated_public_key(key),
3780 Err(ExecutorError::InvalidParam("public_key"))
3781 ));
3782 }
3783
3784 #[test]
3785 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
3786 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
3793 assert!(validated_public_key(key).is_ok());
3794 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
3797 }
3798
3799 #[test]
3800 fn public_key_rejects_shell_metacharacters() {
3801 for (metachar, desc) in [
3802 (';', "semicolon"),
3803 ('`', "backtick"),
3804 ('$', "dollar"),
3805 ('\\', "backslash"),
3806 ('&', "ampersand"),
3807 ] {
3808 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
3809 assert!(
3810 matches!(
3811 validated_public_key(&key),
3812 Err(ExecutorError::InvalidParam("public_key"))
3813 ),
3814 "{desc} should be rejected"
3815 );
3816 }
3817 }
3818
3819 #[test]
3820 fn public_key_rejects_newline() {
3821 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
3822 assert!(matches!(
3823 validated_public_key(key),
3824 Err(ExecutorError::InvalidParam("public_key"))
3825 ));
3826 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
3827 assert!(matches!(
3828 validated_public_key(key_cr),
3829 Err(ExecutorError::InvalidParam("public_key"))
3830 ));
3831 }
3832
3833 #[test]
3834 fn public_key_rejects_too_long() {
3835 let long_payload = "A".repeat(8192);
3837 let key = format!("ssh-ed25519 {long_payload} user@host");
3838 assert!(matches!(
3839 validated_public_key(&key),
3840 Err(ExecutorError::InvalidParam("public_key"))
3841 ));
3842 }
3843
3844 #[test]
3847 fn str_array_or_empty_rejects_non_string_element() {
3848 let params = json!({ "packages": ["vim", 42, "curl"] });
3849 assert!(matches!(
3850 str_array_or_empty(¶ms, "packages"),
3851 Err(ExecutorError::InvalidParam("packages"))
3852 ));
3853 }
3854
3855 #[test]
3856 fn str_array_or_empty_accepts_string_array() {
3857 let params = json!({ "packages": ["vim", "curl"] });
3858 assert_eq!(
3859 str_array_or_empty(¶ms, "packages").unwrap(),
3860 vec!["vim".to_string(), "curl".to_string()]
3861 );
3862 }
3863
3864 #[test]
3865 fn str_array_or_empty_returns_empty_when_key_absent() {
3866 let params = json!({});
3867 assert_eq!(
3868 str_array_or_empty(¶ms, "packages").unwrap(),
3869 Vec::<String>::new()
3870 );
3871 }
3872
3873 #[test]
3876 fn kernel_arg_allows_safe_args() {
3877 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3878 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3879 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3880 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3881 }
3882
3883 #[test]
3884 fn kernel_arg_blocks_init_override() {
3885 assert!(matches!(
3886 validated_safe_kernel_arg("init=/bin/sh", "add"),
3887 Err(ExecutorError::InvalidParam("add"))
3888 ));
3889 assert!(matches!(
3890 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3891 Err(ExecutorError::InvalidParam("add"))
3892 ));
3893 }
3894
3895 #[test]
3896 fn kernel_arg_blocks_selinux_disable() {
3897 assert!(matches!(
3898 validated_safe_kernel_arg("selinux=0", "add"),
3899 Err(ExecutorError::InvalidParam("add"))
3900 ));
3901 assert!(matches!(
3902 validated_safe_kernel_arg("enforcing=0", "add"),
3903 Err(ExecutorError::InvalidParam("add"))
3904 ));
3905 }
3906
3907 #[test]
3908 fn kernel_arg_blocks_security_override() {
3909 assert!(matches!(
3910 validated_safe_kernel_arg("security=none", "add"),
3911 Err(ExecutorError::InvalidParam("add"))
3912 ));
3913 }
3914
3915 #[test]
3916 fn kernel_arg_blocks_module_blacklist() {
3917 assert!(matches!(
3918 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3919 Err(ExecutorError::InvalidParam("add"))
3920 ));
3921 }
3922
3923 #[test]
3931 fn kernel_arg_removal_blocks_stripping_a_hardening_arg() {
3932 for arg in [
3935 "lockdown=confidentiality",
3936 "module.sig_enforce=1",
3937 "selinux=1",
3938 "enforcing=1",
3939 "apparmor=1",
3940 "security=selinux",
3941 "pti=on",
3942 "mitigations=auto,nosmt",
3943 "init_on_alloc=1",
3944 "init_on_free=1",
3945 "slab_nomerge",
3946 "page_alloc.shuffle=1",
3947 "randomize_kstack_offset=on",
3948 "slub_debug=FZP",
3949 "vsyscall=none",
3950 "debugfs=off",
3951 "iommu=force",
3952 "intel_iommu=on",
3953 "amd_iommu=force_isolation",
3954 ] {
3955 assert!(
3956 matches!(
3957 validated_safe_kernel_arg_removal(arg, "remove"),
3958 Err(ExecutorError::InvalidParam("remove"))
3959 ),
3960 "removing {arg} is a boot-security downgrade and must be refused"
3961 );
3962 }
3963 }
3964
3965 #[test]
3969 fn kernel_arg_removal_allows_undoing_a_weakening_arg() {
3970 for arg in [
3971 "mitigations=off",
3972 "selinux=0",
3973 "enforcing=0",
3974 "apparmor=0",
3975 "pti=off",
3976 "lockdown=none",
3977 "debugfs=on",
3978 "init=/bin/sh",
3979 "single",
3980 ] {
3981 assert!(
3982 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3983 "{arg} weakens the host, so removing it must stay allowed"
3984 );
3985 }
3986 }
3987
3988 #[test]
3989 fn kernel_arg_removal_allows_ordinary_args() {
3990 for arg in [
3991 "quiet",
3992 "splash",
3993 "nomodeset",
3994 "console=ttyS0,115200",
3995 "rd.driver.blacklist=nouveau",
3996 ] {
3997 assert!(
3998 validated_safe_kernel_arg_removal(arg, "remove").is_ok(),
3999 "{arg} carries no boot-security meaning and must stay removable"
4000 );
4001 }
4002 }
4003
4004 #[test]
4005 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
4006 assert!(matches!(
4007 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
4008 Err(ExecutorError::InvalidParam("add"))
4009 ));
4010 assert!(matches!(
4011 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
4012 Err(ExecutorError::InvalidParam("add"))
4013 ));
4014 assert!(matches!(
4015 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
4016 Err(ExecutorError::InvalidParam("add"))
4017 ));
4018 }
4019
4020 #[test]
4021 fn kernel_arg_blocks_apparmor_disable() {
4022 assert!(matches!(
4023 validated_safe_kernel_arg("apparmor=0", "add"),
4024 Err(ExecutorError::InvalidParam("add"))
4025 ));
4026 }
4027
4028 #[test]
4029 fn kernel_arg_blocks_mitigations_off() {
4030 assert!(matches!(
4031 validated_safe_kernel_arg("mitigations=off", "add"),
4032 Err(ExecutorError::InvalidParam("add"))
4033 ));
4034 }
4035
4036 #[test]
4037 fn kernel_arg_blocks_lockdown_override() {
4038 assert!(matches!(
4039 validated_safe_kernel_arg("lockdown=none", "add"),
4040 Err(ExecutorError::InvalidParam("add"))
4041 ));
4042 }
4043
4044 #[test]
4045 fn kernel_arg_blocks_pti_off() {
4046 assert!(matches!(
4047 validated_safe_kernel_arg("pti=off", "add"),
4048 Err(ExecutorError::InvalidParam("add"))
4049 ));
4050 }
4051
4052 #[test]
4053 fn kernel_arg_blocks_smap_smep_disable() {
4054 assert!(matches!(
4055 validated_safe_kernel_arg("nosmap", "add"),
4056 Err(ExecutorError::InvalidParam("add"))
4057 ));
4058 assert!(matches!(
4059 validated_safe_kernel_arg("nosmep", "add"),
4060 Err(ExecutorError::InvalidParam("add"))
4061 ));
4062 }
4063
4064 #[test]
4065 fn kernel_arg_blocks_single_user_shortcuts() {
4066 assert!(matches!(
4068 validated_safe_kernel_arg("single", "add"),
4069 Err(ExecutorError::InvalidParam("add"))
4070 ));
4071 assert!(matches!(
4072 validated_safe_kernel_arg("s", "add"),
4073 Err(ExecutorError::InvalidParam("add"))
4074 ));
4075 assert!(matches!(
4076 validated_safe_kernel_arg("1", "add"),
4077 Err(ExecutorError::InvalidParam("add"))
4078 ));
4079 }
4080
4081 #[test]
4082 fn signal_process_guardrails() {
4083 for bad_pid in [0, 1] {
4085 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
4086 assert!(
4087 matches!(err, ExecutorError::InvalidParam("pid")),
4088 "pid {bad_pid} must be rejected, got {err:?}"
4089 );
4090 }
4091 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
4093 .unwrap_err();
4094 assert!(
4095 matches!(err, ExecutorError::InvalidParam("signal")),
4096 "got {err:?}"
4097 );
4098 assert!(matches!(
4100 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
4101 ExecutorError::MissingParam("pid")
4102 ));
4103 let spec = build_action_spec(
4105 "SignalProcess",
4106 &json!({ "pid": "4242", "signal": "sigkill" }),
4107 )
4108 .unwrap();
4109 assert_eq!(spec.action_name, "SignalProcess");
4110 assert_eq!(
4111 spec.mechanism,
4112 ActionMechanism::Command {
4113 program: "sudo",
4114 args: vec![
4115 "kill".to_string(),
4116 "-s".to_string(),
4117 "KILL".to_string(),
4118 "4242".to_string()
4119 ],
4120 }
4121 );
4122 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
4124 if let ActionMechanism::Command { args, .. } = spec.mechanism {
4125 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
4126 } else {
4127 panic!("expected Command mechanism");
4128 }
4129 }
4130
4131 #[test]
4132 fn create_scheduled_job_validates_name_command_schedule() {
4133 assert!(matches!(
4135 build_action_spec(
4136 "CreateScheduledJob",
4137 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
4138 )
4139 .unwrap_err(),
4140 ExecutorError::InvalidParam("command")
4141 ));
4142 assert!(matches!(
4144 build_action_spec(
4145 "CreateScheduledJob",
4146 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
4147 )
4148 .unwrap_err(),
4149 ExecutorError::InvalidParam("name")
4150 ));
4151 assert!(matches!(
4153 build_action_spec(
4154 "CreateScheduledJob",
4155 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
4156 )
4157 .unwrap_err(),
4158 ExecutorError::InvalidParam("schedule")
4159 ));
4160 let spec = build_action_spec(
4162 "CreateScheduledJob",
4163 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
4164 )
4165 .unwrap();
4166 assert_eq!(
4167 spec.mechanism,
4168 ActionMechanism::Command {
4169 program: "sudo",
4170 args: vec![
4171 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
4172 "--name".to_string(),
4173 "nightly-backup".to_string(),
4174 "--command".to_string(),
4175 "/usr/bin/backup --full".to_string(),
4176 "--schedule".to_string(),
4177 "*-*-* 02:00:00".to_string(),
4178 ],
4179 }
4180 );
4181 }
4182
4183 #[test]
4184 fn set_sshd_option_allowlist_guardrails() {
4185 assert!(matches!(
4187 build_action_spec(
4188 "SetSshdOption",
4189 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
4190 )
4191 .unwrap_err(),
4192 ExecutorError::InvalidParam("option")
4193 ));
4194 assert!(matches!(
4196 build_action_spec(
4197 "SetSshdOption",
4198 &json!({ "option": "PermitRootLogin", "value": "maybe" })
4199 )
4200 .unwrap_err(),
4201 ExecutorError::InvalidParam("value")
4202 ));
4203 let spec = build_action_spec(
4205 "SetSshdOption",
4206 &json!({ "option": "PasswordAuthentication", "value": "no" }),
4207 )
4208 .unwrap();
4209 assert_eq!(
4210 spec.mechanism,
4211 ActionMechanism::Command {
4212 program: "sudo",
4213 args: vec![
4214 "/usr/lib/sysknife/sshd-option-edit".to_string(),
4215 "--option".to_string(),
4216 "PasswordAuthentication".to_string(),
4217 "--value".to_string(),
4218 "no".to_string(),
4219 ],
4220 }
4221 );
4222 }
4223
4224 #[test]
4225 fn configure_unattended_upgrades_toggles_helper_flag() {
4226 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
4227 let spec = build_action_spec(
4228 "ConfigureUnattendedUpgrades",
4229 &json!({ "enabled": enabled }),
4230 )
4231 .unwrap();
4232 assert_eq!(
4233 spec.mechanism,
4234 ActionMechanism::Command {
4235 program: "sudo",
4236 args: vec![
4237 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
4238 flag.to_string(),
4239 ],
4240 }
4241 );
4242 }
4243 assert!(matches!(
4244 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
4245 ExecutorError::MissingParam("enabled")
4246 ));
4247 }
4248
4249 #[test]
4250 fn grub_set_kargs_append_blocks_single_user_shortcut() {
4251 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
4259 let err = build_action_spec(
4260 "GrubSetKargs",
4261 &json!({ "append": [dangerous], "delete": [] }),
4262 )
4263 .unwrap_err();
4264 assert!(
4265 matches!(err, ExecutorError::InvalidParam("append")),
4266 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
4267 );
4268 }
4269 assert!(build_action_spec(
4271 "GrubSetKargs",
4272 &json!({ "append": ["quiet"], "delete": [] })
4273 )
4274 .is_ok());
4275 }
4276
4277 #[test]
4278 fn kernel_arg_build_spec_rejects_dangerous_arg() {
4279 let err = build_action_spec(
4285 "SetKernelArguments",
4286 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
4287 )
4288 .unwrap_err();
4289 assert!(
4290 matches!(err, ExecutorError::InvalidParam("add")),
4291 "expected InvalidParam(add), got {err:?}"
4292 );
4293 }
4294
4295 #[test]
4296 fn grub_set_kargs_cannot_delete_a_protective_karg() {
4297 for karg in [
4304 "apparmor=1",
4305 "lockdown=confidentiality",
4306 "mitigations=auto",
4307 "pti=on",
4308 "selinux=1",
4309 ] {
4310 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
4311 .unwrap_err();
4312 assert!(
4313 matches!(err, ExecutorError::InvalidParam("delete")),
4314 "deleting {karg:?} must be refused, got {err:?}"
4315 );
4316 }
4317 }
4318
4319 #[test]
4324 fn rollback_available_matches_rollback_spec_for_all_actions() {
4325 for spec in crate::actions::all_specs() {
4333 let has_rollback = rollback_spec_for(spec.action_name).is_some();
4334 assert_eq!(
4335 spec.rollback_available,
4336 has_rollback,
4337 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
4338 spec.action_name,
4339 spec.rollback_available,
4340 if has_rollback { "Some" } else { "None" },
4341 );
4342 }
4343 }
4344
4345 #[test]
4353 fn create_user_home_must_be_an_absolute_path_without_dot_dot() {
4354 for home in [
4355 "../../etc/skel",
4356 "/home/../etc",
4357 "home/alice",
4358 "/home/alice/../../../root",
4359 ] {
4360 let got =
4361 build_action_spec("CreateUser", &json!({ "username": "alice", "home": home }));
4362 assert!(
4363 got.is_err(),
4364 "CreateUser accepted home={home:?}, which useradd --home-dir runs as root"
4365 );
4366 }
4367 build_action_spec(
4370 "CreateUser",
4371 &json!({ "username": "alice", "home": "/home/alice" }),
4372 )
4373 .expect("a plain absolute home must still be accepted");
4374 }
4375
4376 #[test]
4382 fn create_user_is_high_risk() {
4383 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
4384 assert_eq!(
4385 spec.risk_level,
4386 RiskLevel::High,
4387 "CreateUser must be High (T1136.001 Persistence)"
4388 );
4389 }
4390
4391 #[test]
4392 fn configure_firewall_is_high_risk() {
4393 let spec = build_action_spec(
4394 "ConfigureFirewall",
4395 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
4396 )
4397 .unwrap();
4398 assert_eq!(
4399 spec.risk_level,
4400 RiskLevel::High,
4401 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
4402 );
4403 }
4404
4405 #[test]
4406 fn mask_service_is_high_risk() {
4407 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
4408 assert_eq!(
4409 spec.risk_level,
4410 RiskLevel::High,
4411 "MaskService must be High (T1562.001 Impair Defenses)"
4412 );
4413 }
4414
4415 #[test]
4416 fn add_package_repository_is_high_risk() {
4417 let spec = build_action_spec(
4418 "AddPackageRepository",
4419 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
4420 )
4421 .unwrap();
4422 assert_eq!(
4423 spec.risk_level,
4424 RiskLevel::High,
4425 "AddPackageRepository must be High (supply-chain vector)"
4426 );
4427 }
4428
4429 #[test]
4430 fn set_dns_servers_is_high_risk() {
4431 let spec = build_action_spec(
4432 "SetDnsServers",
4433 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
4434 )
4435 .unwrap();
4436 assert_eq!(
4437 spec.risk_level,
4438 RiskLevel::High,
4439 "SetDnsServers must be High (DNS hijacking / T1557)"
4440 );
4441 }
4442}