1use crate::actions::{
2 apparmor, apt, apt_preferences, auditd, certbot, cloudinit, containers, deployment, distrobox,
3 fail2ban, filesystem, flatpak, grub, identity, journald, layering, livepatch, logging, lvm,
4 mounts, multipass, netplan, network, package_repos, pam, ppa, processes, reboot,
5 release_upgrade, resolvectl, services, snap, ssh, sudoers, sysctl, system_info, toolbox,
6 ubuntu_pro, ufw, users,
7 validate::{
8 validated_activatable_unit, validated_apparmor_profile, validated_apt_package,
9 validated_apt_pin_expr, validated_apt_pin_name, validated_audit_path,
10 validated_audit_perms, validated_cpu_quota, validated_domain, validated_email,
11 validated_fstype, validated_group, validated_group_not_critical, validated_hostname,
12 validated_install_package, validated_journal_grep, validated_journal_priority,
13 validated_journal_time, validated_locale, validated_log_path, validated_lvm_name,
14 validated_lvm_size, validated_memory_limit, validated_mount_device,
15 validated_mount_options, validated_mount_point, validated_port_or_service,
16 validated_ppa_name, validated_pro_service, validated_safe_arg, validated_sudo_commands,
17 validated_sudoers_name, validated_swap_path, validated_sysctl_key, validated_sysctl_value,
18 validated_syslog_host, validated_tasks_max, validated_timezone, validated_unit_name,
19 validated_username, validated_username_not_critical,
20 },
21 ActionMechanism, ActionSpec,
22};
23use async_trait::async_trait;
24use serde_json::Value;
25use std::io;
26use std::net::IpAddr;
27use std::process::Stdio;
28use std::str::FromStr;
29use std::time::Duration;
30use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
31use tokio::sync::mpsc::UnboundedSender;
32
33const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
44const MAX_SCHEDULE_EXPR_LEN: usize = 128;
46const MAX_JOURNAL_LINES: u64 = 10_000;
49const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
51const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
53const MAX_PASSWORD_MINLEN: u64 = 128;
55const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
57const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
59
60#[derive(Debug, thiserror::Error)]
61pub enum ExecutorError {
62 #[error("unknown action: {0}")]
63 UnknownAction(String),
64
65 #[error("missing required param: {0}")]
66 MissingParam(&'static str),
67
68 #[error("invalid param type for: {0}")]
69 InvalidParam(&'static str),
70
71 #[error("invalid IP address for param '{param}': '{value}'")]
77 InvalidIpAddress { param: &'static str, value: String },
78
79 #[error("io error: {0}")]
80 Io(#[from] io::Error),
81
82 #[error(
91 "{program} exceeded the {timeout_secs}s action timeout and could not be \
92 confirmed stopped: process group {pgid} still has members. Automatic \
93 rollback was skipped because the original action may still be running; \
94 inspect the host before retrying"
95 )]
96 ActionNotStopped {
97 program: String,
98 pgid: i32,
99 timeout_secs: u64,
100 },
101}
102
103#[derive(Debug, Clone, PartialEq, Eq)]
112pub struct ExecutionOutput {
113 pub stdout: String,
114 pub stderr: String,
115 pub exit_code: i32,
116}
117
118impl ExecutionOutput {
119 pub fn is_success(&self) -> bool {
121 self.exit_code == 0
122 }
123
124 pub fn is_nonzero(&self) -> bool {
126 self.exit_code != 0
127 }
128}
129
130#[async_trait]
137pub trait ActionExecutor: Send + Sync {
138 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
140
141 async fn execute_with_progress(
147 &self,
148 spec: &ActionSpec,
149 progress: UnboundedSender<String>,
150 ) -> Result<ExecutionOutput, ExecutorError> {
151 let output = self.execute(spec).await?;
152 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
153 let _ = progress.send(line.to_string());
154 }
155 Ok(output)
156 }
157}
158
159pub struct RealActionExecutor;
161
162#[async_trait]
163impl ActionExecutor for RealActionExecutor {
164 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
165 execute_spec(spec).await
166 }
167
168 async fn execute_with_progress(
169 &self,
170 spec: &ActionSpec,
171 progress: UnboundedSender<String>,
172 ) -> Result<ExecutionOutput, ExecutorError> {
173 match &spec.mechanism {
174 ActionMechanism::Command { program, args } => {
175 execute_command_with_progress(program, args, progress).await
176 }
177 _ => execute_spec(spec).await,
178 }
179 }
180}
181
182const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
196
197fn action_timeout() -> Duration {
198 match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
199 Ok(raw) => match raw.trim().parse::<u64>() {
200 Ok(secs) if secs > 0 => Duration::from_secs(secs),
201 _ => {
202 eprintln!(
203 "[sysknife-daemon] WARNING: ignoring invalid \
204 SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
205 );
206 DEFAULT_ACTION_TIMEOUT
207 }
208 },
209 Err(_) => DEFAULT_ACTION_TIMEOUT,
210 }
211}
212
213#[derive(Debug, Clone, Copy, PartialEq, Eq)]
215enum GroupState {
216 Gone,
218 AliveReachable,
220 AliveUnreachable,
225 Unknown(i32),
228}
229
230impl GroupState {
231 fn is_gone(self) -> bool {
232 matches!(self, GroupState::Gone)
233 }
234}
235
236fn probe_group(pgid: i32) -> GroupState {
238 let rc = unsafe { libc::killpg(pgid, 0) };
243 if rc == 0 {
244 return GroupState::AliveReachable;
245 }
246 match io::Error::last_os_error().raw_os_error() {
247 Some(libc::ESRCH) => GroupState::Gone,
248 Some(libc::EPERM) => GroupState::AliveUnreachable,
249 other => GroupState::Unknown(other.unwrap_or(0)),
250 }
251}
252
253fn signal_group(pgid: i32, signal: i32) -> Result<(), io::Error> {
255 let rc = unsafe { libc::killpg(pgid, signal) };
257 if rc == 0 {
258 return Ok(());
259 }
260 let err = io::Error::last_os_error();
261 if err.raw_os_error() == Some(libc::ESRCH) {
262 return Ok(());
263 }
264 Err(err)
265}
266
267const GROUP_TERM_GRACE: Duration = Duration::from_secs(5);
272
273const REAP_WAIT_CAP: Duration = Duration::from_secs(2);
281
282async fn kill_and_reap(child: &mut tokio::process::Child, program: &str) -> ExecutorError {
300 let secs = action_timeout().as_secs();
301 let Some(pgid) = child.id().map(|pid| pid as i32) else {
302 eprintln!(
305 "[sysknife-daemon] {program} exceeded the {secs}s action timeout; already exited"
306 );
307 return timed_out(program, secs);
308 };
309
310 let _ = child.start_kill();
314 if let Err(err) = signal_group(pgid, libc::SIGTERM) {
315 eprintln!("[sysknife-daemon] SIGTERM to process group {pgid} failed: {err}");
316 }
317
318 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
322
323 let grace = tokio::time::Instant::now() + GROUP_TERM_GRACE;
324 while tokio::time::Instant::now() < grace && !probe_group(pgid).is_gone() {
325 tokio::time::sleep(Duration::from_millis(50)).await;
326 }
327 if !probe_group(pgid).is_gone() {
328 if let Err(err) = signal_group(pgid, libc::SIGKILL) {
329 eprintln!("[sysknife-daemon] SIGKILL to process group {pgid} failed: {err}");
330 }
331 tokio::time::sleep(Duration::from_millis(200)).await;
332 let _ = tokio::time::timeout(REAP_WAIT_CAP, child.wait()).await;
333 }
334
335 match probe_group(pgid) {
336 GroupState::Gone => {
337 eprintln!(
338 "[sysknife-daemon] {program} exceeded the {secs}s action timeout; \
339 process group {pgid} stopped"
340 );
341 timed_out(program, secs)
342 }
343 state => {
344 eprintln!(
345 "[sysknife-daemon] {program} exceeded the {secs}s action timeout and \
346 process group {pgid} could not be confirmed stopped ({state:?})"
347 );
348 ExecutorError::ActionNotStopped {
349 program: program.to_string(),
350 pgid,
351 timeout_secs: secs,
352 }
353 }
354 }
355}
356
357fn timed_out(program: &str, secs: u64) -> ExecutorError {
359 ExecutorError::Io(io::Error::new(
360 io::ErrorKind::TimedOut,
361 format!("{program} exceeded the {secs}s action timeout and was stopped"),
362 ))
363}
364
365async fn execute_command_with_progress(
366 program: &'static str,
367 args: &[String],
368 progress: UnboundedSender<String>,
369) -> Result<ExecutionOutput, ExecutorError> {
370 let mut child = tokio::process::Command::new(program)
371 .args(args)
372 .process_group(0)
377 .stdin(Stdio::null())
382 .stdout(Stdio::piped())
383 .stderr(Stdio::piped())
384 .spawn()
385 .map_err(ExecutorError::Io)?;
386
387 let stdout = child.stdout.take().expect("stdout was piped");
388 let stderr = child.stderr.take().expect("stderr was piped");
389 let stderr_task = tokio::spawn(async move {
390 let mut buf = Vec::new();
391 BufReader::new(stderr)
392 .read_to_end(&mut buf)
393 .await
394 .map(|_| buf)
395 });
396
397 let deadline = tokio::time::Instant::now() + action_timeout();
398
399 let mut lines = BufReader::new(stdout).lines();
400 let mut stdout_buf = String::new();
401 loop {
402 let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
405 Ok(result) => result.map_err(ExecutorError::Io)?,
406 Err(_) => return Err(kill_and_reap(&mut child, program).await),
407 };
408 let Some(line) = next else { break };
409 if !line.is_empty() {
410 let _ = progress.send(line.clone());
411 }
412 stdout_buf.push_str(&line);
413 stdout_buf.push('\n');
414 }
415
416 let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
417 Ok(status) => status.map_err(ExecutorError::Io)?,
418 Err(_) => return Err(kill_and_reap(&mut child, program).await),
419 };
420 let stderr_bytes = stderr_task
421 .await
422 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
423 .map_err(ExecutorError::Io)?;
424
425 Ok(ExecutionOutput {
426 stdout: stdout_buf,
427 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
428 exit_code: exit_status.code().unwrap_or(-1),
429 })
430}
431
432pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
437 match action_name {
438 "GetSystemState" => Ok(deployment::get_system_state()),
440 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
441 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
442 "ListDeployments" => Ok(deployment::list_deployments()),
443 "UpdateSystem" => Ok(deployment::update_system()),
444 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
445 "RebootSystem" => Ok(deployment::reboot_system()),
446 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
447 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
448
449 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
451 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
452 "RebaseSystem" => {
453 let target_ref = require_str(params, "target_ref")?;
454 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
455 Ok(deployment::rebase_system(&target_ref))
456 }
457 "SetKernelArguments" => {
458 let add = str_array_or_empty(params, "add")?;
459 let remove = str_array_or_empty(params, "remove")?;
460 for arg in add.iter() {
463 validated_safe_kernel_arg(arg, "add")?;
464 }
465 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
466 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
467 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
468 }
469
470 "ListFlatpakRemotes" => {
476 let username = validated_username(resolve_username(params)?, "username")?;
477 Ok(flatpak::list_flatpak_remotes(&username))
478 }
479 "InstallFlatpak" => {
480 let username = validated_username(resolve_username(params)?, "username")?;
481 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
482 let remote = params
486 .get("remote")
487 .and_then(|v| v.as_str())
488 .unwrap_or("flathub");
489 let remote = validated_safe_arg(remote, "remote")?;
490 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
491 }
492 "RemoveFlatpak" => {
493 let username = validated_username(resolve_username(params)?, "username")?;
494 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
495 Ok(flatpak::remove_flatpak(&username, &app_id))
496 }
497 "SearchFlatpakApps" => {
498 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
499 Ok(flatpak::search_flatpak_apps(&term))
500 }
501 "AddFlatpakRemote" => {
502 let username = validated_username(resolve_username(params)?, "username")?;
503 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
504 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
505 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
506 }
507 "RemoveFlatpakRemote" => {
508 let username = validated_username(resolve_username(params)?, "username")?;
509 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
510 Ok(flatpak::remove_flatpak_remote(&username, &remote))
511 }
512 "GetFlatpakAppInfo" => {
513 let username = validated_username(resolve_username(params)?, "username")?;
514 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
515 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
516 }
517 "ListInstalledFlatpaks" => {
518 let username = validated_username(resolve_username(params)?, "username")?;
519 Ok(flatpak::list_installed_flatpaks(&username))
520 }
521 "UpdateFlatpak" => {
522 let username = validated_username(resolve_username(params)?, "username")?;
523 let app_id = params
526 .get("app_id")
527 .and_then(|v| v.as_str())
528 .filter(|id| !id.is_empty())
529 .map(|id| validated_safe_arg(id, "app_id"))
530 .transpose()?;
531 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
532 }
533
534 "ListContainers" => {
540 let username = validated_username(resolve_username(params)?, "username")?;
541 Ok(containers::list_containers(&username))
542 }
543 "CreateContainer" => {
544 let username = validated_username(resolve_username(params)?, "username")?;
545 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
546 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
547 Ok(containers::create_container(&username, &name, &image))
548 }
549 "StartContainer" => {
550 let username = validated_username(resolve_username(params)?, "username")?;
551 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
552 Ok(containers::start_container(&username, &name))
553 }
554 "StopContainer" => {
555 let username = validated_username(resolve_username(params)?, "username")?;
556 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
557 Ok(containers::stop_container(&username, &name))
558 }
559 "RemoveContainer" => {
560 let username = validated_username(resolve_username(params)?, "username")?;
561 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
562 Ok(containers::remove_container(&username, &name))
563 }
564 "GetContainerInfo" => {
565 let username = validated_username(resolve_username(params)?, "username")?;
566 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
567 Ok(containers::get_container_info(&username, &name))
568 }
569
570 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
572 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
573 "InstallPackages" => {
574 let pkgs = str_array_or_empty(params, "packages")?;
575 let validated: Vec<String> = pkgs
576 .iter()
577 .map(|p| validated_install_package(p, "packages"))
578 .collect::<Result<_, _>>()?;
579 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
580 Ok(layering::install_packages(&refs))
581 }
582 "RemovePackages" => {
583 let pkgs = str_array_or_empty(params, "packages")?;
584 let validated: Vec<String> = pkgs
585 .iter()
586 .map(|p| validated_install_package(p, "packages"))
587 .collect::<Result<_, _>>()?;
588 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
589 Ok(layering::remove_packages(&refs))
590 }
591 "AddLayeredPackage" => {
592 let package = validated_install_package(require_str(params, "package")?, "package")?;
593 Ok(layering::add_layered_package(&package))
594 }
595 "RemoveLayeredPackage" => {
596 let package = validated_install_package(require_str(params, "package")?, "package")?;
597 Ok(layering::remove_layered_package(&package))
598 }
599 "ReplaceLayeredPackage" => {
600 let old = validated_install_package(require_str(params, "old")?, "old")?;
601 let new = validated_install_package(require_str(params, "new")?, "new")?;
602 Ok(layering::replace_layered_package(&old, &new))
603 }
604 "RemoveBasePackage" => {
605 let package = validated_install_package(require_str(params, "package")?, "package")?;
606 Ok(layering::remove_base_package(&package))
607 }
608 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
609
610 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
612 "AddPackageRepository" => Ok(package_repos::add_package_repository(
613 validated_repo_id(params)?,
614 validated_no_newline(params, "repo_url")?,
615 )),
616 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
617 validated_repo_id(params)?,
618 )),
619 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
620 validated_repo_id(params)?,
621 )),
622 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
623 validated_repo_id(params)?,
624 )),
625
626 "ListServices" => Ok(services::list_services()),
628 "StartService" => {
629 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
630 Ok(services::start_service(&unit))
631 }
632 "StopService" => {
633 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
634 Ok(services::stop_service(&unit))
635 }
636 "RestartService" => {
637 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
638 Ok(services::restart_service(&unit))
639 }
640 "SetServiceEnabled" => {
641 let enabled = require_bool(params, "enabled")?;
642 let unit = if enabled {
645 validated_activatable_unit(require_str(params, "unit")?, "unit")?
646 } else {
647 validated_unit_name(require_str(params, "unit")?, "unit")?
648 };
649 Ok(services::set_service_enabled(&unit, enabled))
650 }
651 "MaskService" => {
652 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
653 Ok(services::mask_service(&unit))
654 }
655 "UnmaskService" => {
656 let unit = validated_activatable_unit(require_str(params, "unit")?, "unit")?;
657 Ok(services::unmask_service(&unit))
658 }
659 "GetServiceLogs" => {
660 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
661 Ok(services::get_service_logs(&unit))
662 }
663 "GetServiceStatus" => {
664 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
665 Ok(services::get_service_status(&unit))
666 }
667 "ReloadService" => {
668 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
669 Ok(services::reload_service(&unit))
670 }
671 "ListTimers" => Ok(services::list_timers()),
672 "ReloadDaemon" => Ok(services::reload_daemon()),
673 "CreateScheduledJob" => {
674 let name = require_str(params, "name")?;
676 if name.is_empty()
677 || name.len() > 64
678 || !name
679 .chars()
680 .next()
681 .is_some_and(|c| c.is_ascii_alphanumeric())
682 || !name
683 .chars()
684 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
685 {
686 return Err(ExecutorError::InvalidParam("name"));
687 }
688 let command = require_str(params, "command")?;
691 if command.is_empty()
692 || command.len() > MAX_SCHEDULED_COMMAND_LEN
693 || command.chars().any(|c| c.is_control())
694 {
695 return Err(ExecutorError::InvalidParam("command"));
696 }
697 let schedule = require_str(params, "schedule")?;
700 if schedule.is_empty()
701 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
702 || !schedule.chars().all(|c| {
703 c.is_ascii_alphanumeric()
704 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
705 })
706 {
707 return Err(ExecutorError::InvalidParam("schedule"));
708 }
709 Ok(services::create_scheduled_job(name, command, schedule))
710 }
711 "GetServiceResourceLimits" => {
712 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
713 Ok(services::get_service_resource_limits(&unit))
714 }
715 "SetServiceResourceLimits" => {
716 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
717 let mut assignments = Vec::new();
720 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
721 assignments.push(format!("MemoryMax={v}"));
722 }
723 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
724 assignments.push(format!("MemoryHigh={v}"));
725 }
726 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
727 assignments.push(format!("CPUQuota={v}"));
728 }
729 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
730 assignments.push(format!("TasksMax={v}"));
731 }
732 if assignments.is_empty() {
733 return Err(ExecutorError::MissingParam("memory_max"));
734 }
735 Ok(services::set_service_resource_limits(&unit, &assignments))
736 }
737
738 "ListToolboxes" => {
742 let username = validated_username(resolve_username(params)?, "username")?;
743 Ok(toolbox::list_toolboxes(&username))
744 }
745 "CreateToolbox" => {
746 let username = validated_username(resolve_username(params)?, "username")?;
747 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
748 let image = params
749 .get("image")
750 .and_then(|v| v.as_str())
751 .map(|img| validated_safe_arg(img, "image"))
752 .transpose()?;
753 let release = params
754 .get("release")
755 .and_then(|v| v.as_str())
756 .map(|r| validated_safe_arg(r, "release"))
757 .transpose()?;
758 Ok(toolbox::create_toolbox(
759 &username,
760 &name,
761 release.as_deref(),
762 image.as_deref(),
763 ))
764 }
765 "RemoveToolbox" => {
766 let username = validated_username(resolve_username(params)?, "username")?;
767 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
768 Ok(toolbox::remove_toolbox(&username, &name))
769 }
770
771 "GetDateTime" => Ok(identity::get_datetime()),
773 "SetHostname" => {
774 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
775 Ok(identity::set_hostname(&hostname))
776 }
777 "SetTimezone" => {
778 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
779 Ok(identity::set_timezone(&timezone))
780 }
781 "SetLocale" => {
782 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
783 Ok(identity::set_locale(&locale))
784 }
785 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
786
787 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
789
790 "ListProcesses" => Ok(processes::list_processes_spec()),
792 "SignalProcess" => {
793 let pid = params
795 .get("pid")
796 .and_then(|v| {
797 v.as_u64()
798 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
799 })
800 .ok_or(ExecutorError::MissingParam("pid"))?;
801 if pid < 2 || pid > u32::MAX as u64 {
804 return Err(ExecutorError::InvalidParam("pid"));
805 }
806 let signal = validated_kill_signal(
807 params
808 .get("signal")
809 .and_then(|v| v.as_str())
810 .unwrap_or("TERM"),
811 )?;
812 Ok(processes::signal_process(pid as u32, signal))
813 }
814
815 "GetJournalLog" => {
817 let unit = optional_validated(params, "unit", validated_unit_name)?;
818 let priority = optional_validated(params, "priority", validated_journal_priority)?;
819 let since = optional_validated(params, "since", validated_journal_time)?;
820 let until = optional_validated(params, "until", validated_journal_time)?;
821 let grep = optional_validated(params, "grep", validated_journal_grep)?;
822 let lines = params
825 .get("lines")
826 .and_then(|v| v.as_u64())
827 .unwrap_or(100)
828 .clamp(1, MAX_JOURNAL_LINES) as u32;
829 let boot = params
830 .get("boot")
831 .and_then(|v| v.as_bool())
832 .unwrap_or(false);
833 let kernel = params
834 .get("kernel")
835 .and_then(|v| v.as_bool())
836 .unwrap_or(false);
837 Ok(journald::get_journal_log(&journald::JournalQuery {
838 lines,
839 unit: unit.as_deref(),
840 priority: priority.as_deref(),
841 boot,
842 kernel,
843 since: since.as_deref(),
844 until: until.as_deref(),
845 grep: grep.as_deref(),
846 }))
847 }
848 "VacuumJournal" => {
849 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
851 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
852 match (size_mb, retain_days) {
853 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
854 Ok(journald::vacuum_journal_by_size(mb as u32))
855 }
856 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
857 Ok(journald::vacuum_journal_by_time(days as u32))
858 }
859 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
860 _ => Err(ExecutorError::InvalidParam("size_mb")),
862 }
863 }
864
865 "GetLvmReport" => Ok(lvm::get_lvm_report()),
867 "ExtendLogicalVolume" => {
868 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
869 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
870 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
871 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
872 }
873 "CreateLogicalVolume" => {
874 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
875 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
876 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
877 Ok(lvm::create_logical_volume(&vg, &name, &size))
878 }
879 "CreateLvSnapshot" => {
880 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
881 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
882 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
883 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
884 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
885 }
886
887 "GetSysctl" => {
889 let key = optional_validated(params, "key", validated_sysctl_key)?;
891 Ok(sysctl::get_sysctl(key.as_deref()))
892 }
893 "SetSysctl" => {
894 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
895 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
896 Ok(sysctl::set_sysctl(&key, &value))
897 }
898
899 "GetMounts" => Ok(mounts::get_mounts()),
901 "AddMount" => {
902 let device = validated_mount_device(require_str(params, "device")?, "device")?;
903 let mountpoint =
904 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
905 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
906 let options = optional_validated(params, "options", validated_mount_options)?;
907 Ok(mounts::add_mount(
908 &device,
909 &mountpoint,
910 &fstype,
911 options.as_deref(),
912 ))
913 }
914 "RemoveMount" => {
915 let mountpoint =
916 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
917 Ok(mounts::remove_mount(&mountpoint))
918 }
919 "AddSwap" => {
920 let file = validated_swap_path(require_str(params, "file")?, "file")?;
921 let size_mb = require_u32(params, "size_mb")?;
922 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
924 return Err(ExecutorError::InvalidParam("size_mb"));
925 }
926 Ok(mounts::add_swap(&file, size_mb))
927 }
928 "RemoveSwap" => {
929 let file = validated_swap_path(require_str(params, "file")?, "file")?;
930 Ok(mounts::remove_swap(&file))
931 }
932
933 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
935 "GrantSudoAccess" => {
936 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
937 let user = validated_username(require_str(params, "user")?, "user")?;
938 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
939 let runas = match params
941 .get("runas")
942 .and_then(|v| v.as_str())
943 .filter(|s| !s.is_empty())
944 {
945 Some("ALL") => Some("ALL".to_string()),
946 Some(u) => Some(validated_username(u, "runas")?),
947 None => None,
948 };
949 let nopasswd = params
950 .get("nopasswd")
951 .and_then(|v| v.as_bool())
952 .unwrap_or(false);
953 if commands == "ALL" && nopasswd {
963 return Err(ExecutorError::InvalidParam("commands"));
964 }
965 Ok(sudoers::grant_sudo_access(
966 &name,
967 &user,
968 &commands,
969 runas.as_deref(),
970 nopasswd,
971 ))
972 }
973 "RevokeSudoAccess" => {
974 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
975 Ok(sudoers::revoke_sudo_access(&name))
976 }
977
978 "GetAptPins" => {
980 let package = optional_validated(params, "package", validated_apt_package)?;
981 Ok(apt_preferences::get_apt_pins(package.as_deref()))
982 }
983 "SetAptPin" => {
984 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
985 let package = validated_apt_package(require_str(params, "package")?, "package")?;
986 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
987 let priority = params
988 .get("priority")
989 .and_then(|v| v.as_i64())
990 .ok_or(ExecutorError::MissingParam("priority"))?;
991 if !(-1..=1000).contains(&priority) {
992 return Err(ExecutorError::InvalidParam("priority"));
993 }
994 Ok(apt_preferences::set_apt_pin(
995 &name, &package, &pin, priority,
996 ))
997 }
998 "RemoveAptPin" => {
999 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
1000 Ok(apt_preferences::remove_apt_pin(&name))
1001 }
1002
1003 "GetLogrotateStatus" => {
1005 let config = optional_validated(params, "config", validated_log_path)?;
1006 Ok(logging::get_logrotate_status(config.as_deref()))
1007 }
1008 "ConfigureLogRotation" => {
1009 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1010 let path = validated_log_path(require_str(params, "path")?, "path")?;
1011 let frequency = require_str(params, "frequency")?;
1012 if !matches!(frequency, "daily" | "weekly" | "monthly") {
1013 return Err(ExecutorError::InvalidParam("frequency"));
1014 }
1015 let rotate = require_u32(params, "rotate")?;
1016 if rotate > 1000 {
1017 return Err(ExecutorError::InvalidParam("rotate"));
1018 }
1019 let compress = params
1020 .get("compress")
1021 .and_then(|v| v.as_bool())
1022 .unwrap_or(false);
1023 Ok(logging::configure_log_rotation(
1024 &name, &path, frequency, rotate, compress,
1025 ))
1026 }
1027 "RemoveLogRotation" => {
1028 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1029 Ok(logging::remove_log_rotation(&name))
1030 }
1031 "ConfigureRemoteSyslog" => {
1032 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
1033 let port = require_u32(params, "port")?;
1034 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
1035 return Err(ExecutorError::InvalidParam("port"));
1036 }
1037 let protocol = require_str(params, "protocol")?;
1038 if !matches!(protocol, "tcp" | "udp") {
1039 return Err(ExecutorError::InvalidParam("protocol"));
1040 }
1041 Ok(logging::configure_remote_syslog(
1042 &host,
1043 port as u16,
1044 protocol,
1045 ))
1046 }
1047 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
1048
1049 "GetPasswordAging" => {
1051 let user = validated_username(require_str(params, "user")?, "user")?;
1052 Ok(pam::get_password_aging(&user))
1053 }
1054 "SetPasswordAging" => {
1055 let user = validated_username(require_str(params, "user")?, "user")?;
1056 let mut flags = Vec::new();
1058 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
1059 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1060 if n > MAX_PASSWORD_AGE_DAYS {
1061 return Err(ExecutorError::InvalidParam(key));
1062 }
1063 flags.push(opt.to_string());
1064 flags.push(n.to_string());
1065 }
1066 }
1067 if flags.is_empty() {
1068 return Err(ExecutorError::MissingParam("max_days"));
1069 }
1070 Ok(pam::set_password_aging(&user, &flags))
1071 }
1072 "SetPasswordPolicy" => {
1073 let mut extra = Vec::new();
1076 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
1077 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
1078 return Err(ExecutorError::InvalidParam("minlen"));
1079 }
1080 extra.push("--minlen".to_string());
1081 extra.push(n.to_string());
1082 }
1083 for (key, flag) in [
1084 ("dcredit", "--dcredit"),
1085 ("ucredit", "--ucredit"),
1086 ("lcredit", "--lcredit"),
1087 ("ocredit", "--ocredit"),
1088 ] {
1089 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
1090 if !(-64..=64).contains(&n) {
1091 return Err(ExecutorError::InvalidParam(key));
1092 }
1093 extra.push(flag.to_string());
1094 extra.push(n.to_string());
1095 }
1096 }
1097 if extra.is_empty() {
1098 return Err(ExecutorError::MissingParam("minlen"));
1099 }
1100 Ok(pam::set_password_policy(&extra))
1101 }
1102 "SetAccountLockout" => {
1103 let mut extra = Vec::new();
1105 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
1106 if !(1..=1000).contains(&n) {
1107 return Err(ExecutorError::InvalidParam("deny"));
1108 }
1109 extra.push("--deny".to_string());
1110 extra.push(n.to_string());
1111 }
1112 for (key, flag) in [
1113 ("unlock_time", "--unlock-time"),
1114 ("fail_interval", "--fail-interval"),
1115 ] {
1116 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1117 if n > MAX_LOCKOUT_WINDOW_SECS {
1118 return Err(ExecutorError::InvalidParam(key));
1119 }
1120 extra.push(flag.to_string());
1121 extra.push(n.to_string());
1122 }
1123 }
1124 if extra.is_empty() {
1125 return Err(ExecutorError::MissingParam("deny"));
1126 }
1127 Ok(pam::set_account_lockout(&extra))
1128 }
1129
1130 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
1132
1133 "GetFirewallState" => Ok(network::get_firewall_state()),
1135 "GetNetworkStatus" => Ok(network::get_network_status()),
1136 "GetListeningPorts" => Ok(network::get_listening_ports()),
1137 "ConfigureWifi" => {
1138 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
1139 let password = params
1141 .get("password")
1142 .and_then(|v| v.as_str())
1143 .filter(|p| !p.is_empty())
1144 .map(|p| validated_safe_arg(p, "password"))
1145 .transpose()?;
1146 Ok(network::configure_wifi(&ssid, password.as_deref()))
1147 }
1148 "SetDnsServers" => {
1149 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1150 let servers = str_array_or_empty(params, "servers")?;
1151 let validated: Vec<String> = servers
1152 .iter()
1153 .map(|s| validated_safe_arg(s, "servers"))
1154 .collect::<Result<_, _>>()?;
1155 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
1156 Ok(network::set_dns_servers(&interface, &refs))
1157 }
1158 "ConfigureFirewall" => {
1159 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
1160 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
1161 Ok(network::configure_firewall(
1162 &zone,
1163 &service,
1164 require_bool(params, "enabled")?,
1165 ))
1166 }
1167
1168 "ListUsers" => Ok(users::list_users()),
1170 "ListGroups" => Ok(users::list_groups()),
1171 "CreateUser" => {
1172 let username = validated_username(resolve_username(params)?, "username")?;
1173 let shell = params
1174 .get("shell")
1175 .and_then(|v| v.as_str())
1176 .map(|s| validated_safe_arg(s, "shell"))
1177 .transpose()?;
1178 let home = params
1179 .get("home")
1180 .and_then(|v| v.as_str())
1181 .map(|h| validated_safe_arg(h, "home"))
1182 .transpose()?;
1183 Ok(users::create_user(
1184 &username,
1185 shell.as_deref(),
1186 home.as_deref(),
1187 ))
1188 }
1189 "DeleteUser" => {
1190 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1191 Ok(users::delete_user(&username))
1192 }
1193 "AddUserToGroup" => {
1194 let username = validated_username(resolve_username(params)?, "username")?;
1195 let group = validated_group(require_str(params, "group")?, "group")?;
1196 Ok(users::add_user_to_group(&username, &group))
1197 }
1198 "RemoveUserFromGroup" => {
1199 let username = validated_username(resolve_username(params)?, "username")?;
1200 let group = validated_group(require_str(params, "group")?, "group")?;
1201 Ok(users::remove_user_from_group(&username, &group))
1202 }
1203 "CreateGroup" => {
1204 let group = validated_group(require_str(params, "group")?, "group")?;
1205 let system = params
1206 .get("system")
1207 .and_then(|v| v.as_bool())
1208 .unwrap_or(false);
1209 Ok(users::create_group(&group, system))
1210 }
1211 "DeleteGroup" => {
1212 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1213 Ok(users::delete_group(&group))
1214 }
1215 "LockUserAccount" => {
1216 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1217 Ok(users::lock_user_account(&username))
1218 }
1219 "UnlockUserAccount" => {
1220 let username = validated_username(resolve_username(params)?, "username")?;
1221 Ok(users::unlock_user_account(&username))
1222 }
1223
1224 "GetAuthorizedKeys" => {
1226 let username = validated_username(resolve_username(params)?, "username")?;
1227 Ok(ssh::get_authorized_keys(&username))
1228 }
1229 "AddAuthorizedKey" => {
1230 let username = validated_username(resolve_username(params)?, "username")?;
1231 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1232 Ok(ssh::add_authorized_key(&username, &public_key))
1233 }
1234 "RemoveAuthorizedKey" => {
1235 let username = validated_username(resolve_username(params)?, "username")?;
1236 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1237 Ok(ssh::remove_authorized_key(&username, &public_key))
1238 }
1239 "SetSshdOption" => {
1240 let option = require_str(params, "option")?;
1244 let value = require_str(params, "value")?;
1245 let allowed_values: &[&str] = match option {
1246 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1247 "PasswordAuthentication"
1248 | "PubkeyAuthentication"
1249 | "X11Forwarding"
1250 | "PermitEmptyPasswords" => &["yes", "no"],
1251 _ => return Err(ExecutorError::InvalidParam("option")),
1252 };
1253 if !allowed_values.contains(&value) {
1254 return Err(ExecutorError::InvalidParam("value"));
1255 }
1256 Ok(ssh::set_sshd_option(option, value))
1257 }
1258
1259 "AptUpdate" => Ok(apt::apt_update()),
1261 "AptUpgrade" => Ok(apt::apt_upgrade()),
1262 "AptInstall" => {
1263 let package = validated_install_package(require_str(params, "package")?, "package")?;
1264 Ok(apt::apt_install(&package))
1265 }
1266 "AptRemove" => {
1267 let package = validated_install_package(require_str(params, "package")?, "package")?;
1268 Ok(apt::apt_remove(&package))
1269 }
1270 "AptPurge" => {
1271 let package = validated_install_package(require_str(params, "package")?, "package")?;
1272 Ok(apt::apt_purge(&package))
1273 }
1274 "AptAutoremove" => Ok(apt::apt_autoremove()),
1275 "AptHold" => {
1276 let package = validated_install_package(require_str(params, "package")?, "package")?;
1277 Ok(apt::apt_hold(&package))
1278 }
1279 "AptUnhold" => {
1280 let package = validated_install_package(require_str(params, "package")?, "package")?;
1281 Ok(apt::apt_unhold(&package))
1282 }
1283 "AptSearch" => {
1284 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1285 Ok(apt::apt_search(&term))
1286 }
1287 "AptListInstalled" => Ok(apt::apt_list_installed()),
1288 "AptShow" => {
1289 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1290 Ok(apt::apt_show(&package))
1291 }
1292 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1293 "AptHistoryList" => Ok(apt::apt_history_list()),
1294 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1295 params, "enabled",
1296 )?)),
1297
1298 "AddPpa" => {
1300 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1301 Ok(ppa::add_ppa(&name))
1302 }
1303 "RemovePpa" => {
1304 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1305 Ok(ppa::remove_ppa(&name))
1306 }
1307
1308 "SnapInstall" => {
1310 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1311 let channel = params
1312 .get("channel")
1313 .and_then(|v| v.as_str())
1314 .filter(|s| !s.is_empty())
1315 .map(|s| validated_safe_arg(s, "channel"))
1316 .transpose()?;
1317 let auto_update = params
1318 .get("auto_update")
1319 .and_then(|v| v.as_bool())
1320 .unwrap_or(false);
1321 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1322 snap::SnapInstallError::InvalidArg { param, .. } => {
1323 ExecutorError::InvalidParam(param)
1324 }
1325 })
1326 }
1327 "SnapRemove" => {
1328 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1329 Ok(snap::snap_remove(&name))
1330 }
1331 "SnapRefresh" => {
1332 let name = params
1333 .get("name")
1334 .and_then(|v| v.as_str())
1335 .filter(|s| !s.is_empty())
1336 .map(|s| validated_safe_arg(s, "name"))
1337 .transpose()?;
1338 Ok(snap::snap_refresh(name.as_deref()))
1339 }
1340 "SnapHold" => {
1341 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1342 Ok(snap::snap_hold(&name))
1343 }
1344 "SnapUnhold" => {
1345 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1346 Ok(snap::snap_unhold(&name))
1347 }
1348 "SnapList" => Ok(snap::snap_list()),
1349 "SnapInfo" => {
1350 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1351 Ok(snap::snap_info(&name))
1352 }
1353 "SnapRevert" => {
1354 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1355 Ok(snap::snap_revert(&name))
1356 }
1357 "SnapClassicInstall" => {
1358 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1359 Ok(snap::snap_classic_install(&name))
1360 }
1361
1362 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1364 "GrubSetKargs" => {
1365 let append: Vec<String> = params
1366 .get("append")
1367 .and_then(|v| v.as_array())
1368 .map(|arr| {
1369 arr.iter()
1370 .filter_map(|v| v.as_str())
1371 .map(String::from)
1372 .collect()
1373 })
1374 .unwrap_or_default();
1375 let delete: Vec<String> = params
1376 .get("delete")
1377 .and_then(|v| v.as_array())
1378 .map(|arr| {
1379 arr.iter()
1380 .filter_map(|v| v.as_str())
1381 .map(String::from)
1382 .collect()
1383 })
1384 .unwrap_or_default();
1385 for a in &append {
1394 validated_safe_arg(a, "append")?;
1395 validated_safe_kernel_arg(a, "append")?;
1396 }
1397 for d in &delete {
1398 validated_safe_arg(d, "delete")?;
1399 }
1400 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1403 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1404 grub::grub_set_kargs(&append_refs, &delete_refs)
1405 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1406 }
1407
1408 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1410
1411 "UfwEnable" => Ok(ufw::ufw_enable()),
1413 "UfwDisable" => Ok(ufw::ufw_disable()),
1414 "UfwAllow" => {
1415 let port_or_service = validated_port_or_service(
1416 require_str(params, "port_or_service")?,
1417 "port_or_service",
1418 )?;
1419 Ok(ufw::ufw_allow(&port_or_service))
1420 }
1421 "UfwDeny" => {
1422 let port_or_service = validated_port_or_service(
1423 require_str(params, "port_or_service")?,
1424 "port_or_service",
1425 )?;
1426 Ok(ufw::ufw_deny(&port_or_service))
1427 }
1428 "UfwReset" => Ok(ufw::ufw_reset()),
1429 "UfwStatus" => Ok(ufw::ufw_status()),
1430
1431 "DistroboxList" => Ok(distrobox::distrobox_list()),
1433 "DistroboxCreate" => {
1434 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1435 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1436 Ok(distrobox::distrobox_create(&name, &image))
1437 }
1438 "DistroboxRemove" => {
1439 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1440 Ok(distrobox::distrobox_remove(&name))
1441 }
1442
1443 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1445 "NetplanApply" => Ok(netplan::netplan_apply()),
1446 "NetplanSet" => {
1447 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1448 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1449 Ok(netplan::netplan_set(&key, &value))
1450 }
1451 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1452
1453 "UfwDeleteRule" => {
1455 let rule_number = require_positive_u32(params, "rule_number")?;
1456 ufw::ufw_delete_rule(rule_number)
1457 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1458 }
1459 "UfwLimit" => {
1460 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1461 Ok(ufw::ufw_limit(&target))
1462 }
1463
1464 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1466 "ProAttach" => {
1467 let token = require_str(params, "token")?;
1469 let token = validated_safe_arg(token, "token")?;
1471 Ok(ubuntu_pro::pro_attach(&token))
1472 }
1473 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1474 "EnableProService" => {
1475 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1476 Ok(ubuntu_pro::enable_pro_service(&service))
1477 }
1478 "DisableProService" => {
1479 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1480 Ok(ubuntu_pro::disable_pro_service(&service))
1481 }
1482
1483 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1485
1486 "MultipassList" => Ok(multipass::multipass_list()),
1488
1489 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1491
1492 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1494 "ResolvectlSetDns" => {
1495 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1496 let raw_servers: Vec<String> = params
1497 .get("servers")
1498 .and_then(|v| v.as_array())
1499 .map(|arr| {
1500 arr.iter()
1501 .filter_map(|v| v.as_str())
1502 .map(String::from)
1503 .collect()
1504 })
1505 .unwrap_or_default();
1506 if raw_servers.is_empty() {
1507 return Err(ExecutorError::MissingParam("servers"));
1508 }
1509 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1513 for s in &raw_servers {
1514 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1515 param: "servers",
1516 value: s.clone(),
1517 })?;
1518 parsed_servers.push(addr);
1519 }
1520 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1521 }
1522
1523 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1525 "AppArmorEnforce" => {
1526 let profile_path =
1527 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1528 Ok(apparmor::apparmor_enforce(&profile_path))
1529 }
1530 "AppArmorComplain" => {
1531 let profile_path =
1532 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1533 Ok(apparmor::apparmor_complain(&profile_path))
1534 }
1535
1536 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1538
1539 "UbuntuInstallFlatpak" => {
1541 let username = validated_username(resolve_username(params)?, "username")?;
1542 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1543 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1544 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1545 }
1546 "UbuntuRemoveFlatpak" => {
1547 let username = validated_username(resolve_username(params)?, "username")?;
1548 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1549 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1550 }
1551 "UbuntuUpdateFlatpak" => {
1552 let username = validated_username(resolve_username(params)?, "username")?;
1553 let app_id = params
1554 .get("app_id")
1555 .and_then(|v| v.as_str())
1556 .filter(|s| !s.is_empty())
1557 .map(|s| validated_safe_arg(s, "app_id"))
1558 .transpose()?;
1559 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1560 }
1561 "UbuntuListFlatpaks" => {
1562 let username = validated_username(resolve_username(params)?, "username")?;
1563 Ok(flatpak::ubuntu_list_flatpaks(&username))
1564 }
1565
1566 "Fail2banStatus" => {
1568 let jail = params
1569 .get("jail")
1570 .and_then(|v| v.as_str())
1571 .filter(|s| !s.is_empty())
1572 .map(|s| validated_safe_arg(s, "jail"))
1573 .transpose()?;
1574 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1575 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1576 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1577 })
1578 }
1579 "Fail2banBanIp" => {
1580 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1581 let ip = require_str(params, "ip")?;
1582 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1583 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1584 param: "ip",
1585 value: v,
1586 },
1587 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1588 })
1589 }
1590 "Fail2banUnbanIp" => {
1591 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1592 let ip = require_str(params, "ip")?;
1593 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1594 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1595 param: "ip",
1596 value: v,
1597 },
1598 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1599 })
1600 }
1601 "ConfigureFail2banJail" => {
1602 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1603 let mut extra = Vec::new();
1604 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1605 extra.push("--enabled".to_string());
1606 extra.push(if n { "true" } else { "false" }.to_string());
1607 }
1608 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1609 if !(1..=100).contains(&n) {
1610 return Err(ExecutorError::InvalidParam("maxretry"));
1611 }
1612 extra.push("--maxretry".to_string());
1613 extra.push(n.to_string());
1614 }
1615 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1616 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1617 if n > MAX_FAIL2BAN_WINDOW_SECS {
1618 return Err(ExecutorError::InvalidParam(key));
1619 }
1620 extra.push(flag.to_string());
1621 extra.push(n.to_string());
1622 }
1623 }
1624 if extra.is_empty() {
1625 return Err(ExecutorError::MissingParam("maxretry"));
1626 }
1627 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
1628 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
1629 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
1630 })
1631 }
1632
1633 "GetAuditRules" => Ok(auditd::get_audit_rules()),
1635 "AddAuditRule" => {
1636 let path = validated_audit_path(require_str(params, "path")?, "path")?;
1637 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1638 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1639 Ok(auditd::add_audit_rule(&path, &perms, &key))
1640 }
1641 "RemoveAuditRule" => {
1642 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1643 Ok(auditd::remove_audit_rule(&key))
1644 }
1645
1646 "GetCertificates" => Ok(certbot::get_certificates()),
1648 "ObtainCertificate" => {
1649 let mut domains = Vec::new();
1651 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1652 for d in arr {
1653 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1654 domains.push(validated_domain(s, "domains")?);
1655 }
1656 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1657 domains.push(validated_domain(d, "domain")?);
1658 }
1659 if domains.is_empty() {
1660 return Err(ExecutorError::MissingParam("domains"));
1661 }
1662 let email = validated_email(require_str(params, "email")?, "email")?;
1663 let challenge = params
1664 .get("challenge")
1665 .and_then(|v| v.as_str())
1666 .unwrap_or("standalone");
1667 if !matches!(challenge, "standalone" | "nginx" | "apache") {
1668 return Err(ExecutorError::InvalidParam("challenge"));
1669 }
1670 Ok(certbot::obtain_certificate(&domains, &email, challenge))
1671 }
1672 "RenewCertificates" => Ok(certbot::renew_certificates()),
1673
1674 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1675 }
1676}
1677
1678pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1684 match &spec.mechanism {
1685 ActionMechanism::Command { program, args } => {
1686 let mut child = tokio::process::Command::new(program)
1690 .args(args)
1691 .stdin(Stdio::null())
1692 .process_group(0)
1695 .stdout(Stdio::piped())
1696 .stderr(Stdio::piped())
1697 .spawn()?;
1698
1699 let stdout_h = child.stdout.take().expect("stdout was piped");
1704 let stderr_h = child.stderr.take().expect("stderr was piped");
1705 let stdout_task = tokio::spawn(async move {
1706 let mut buf = Vec::new();
1707 BufReader::new(stdout_h)
1708 .read_to_end(&mut buf)
1709 .await
1710 .map(|_| buf)
1711 });
1712 let stderr_task = tokio::spawn(async move {
1713 let mut buf = Vec::new();
1714 BufReader::new(stderr_h)
1715 .read_to_end(&mut buf)
1716 .await
1717 .map(|_| buf)
1718 });
1719
1720 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
1721 Ok(status) => status?,
1722 Err(_) => return Err(kill_and_reap(&mut child, program).await),
1723 };
1724 let join =
1725 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
1726 let stdout = stdout_task.await.map_err(join)??;
1727 let stderr = stderr_task.await.map_err(join)??;
1728
1729 Ok(ExecutionOutput {
1730 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1731 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1732 exit_code: status.code().unwrap_or(-1),
1733 })
1734 }
1735 ActionMechanism::FileScan { path } => {
1736 let mut entries = tokio::fs::read_dir(path).await?;
1737 let mut names = Vec::new();
1738 while let Some(entry) = entries.next_entry().await? {
1739 names.push(entry.file_name().to_string_lossy().into_owned());
1740 }
1741 names.sort();
1742 Ok(ExecutionOutput {
1743 stdout: names.join("\n"),
1744 stderr: String::new(),
1745 exit_code: 0,
1746 })
1747 }
1748 ActionMechanism::FileWrite { path, content } => {
1749 if let Some(parent) = std::path::Path::new(path).parent() {
1750 tokio::fs::create_dir_all(parent).await?;
1751 }
1752 tokio::fs::write(path, content).await?;
1753 Ok(ExecutionOutput {
1754 stdout: String::new(),
1755 stderr: String::new(),
1756 exit_code: 0,
1757 })
1758 }
1759 ActionMechanism::FilePatch {
1760 path,
1761 search,
1762 replace,
1763 } => {
1764 let content = tokio::fs::read_to_string(path).await?;
1765 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1766 if patched == content && !search.is_empty() {
1767 return Ok(ExecutionOutput {
1768 stdout: String::new(),
1769 stderr: format!("search string not found in file: {}", path),
1770 exit_code: 1,
1771 });
1772 }
1773 tokio::fs::write(path, patched).await?;
1774 Ok(ExecutionOutput {
1775 stdout: String::new(),
1776 stderr: String::new(),
1777 exit_code: 0,
1778 })
1779 }
1780 ActionMechanism::FileDelete { path } => {
1781 tokio::fs::remove_file(path).await?;
1782 Ok(ExecutionOutput {
1783 stdout: String::new(),
1784 stderr: String::new(),
1785 exit_code: 0,
1786 })
1787 }
1788 }
1789}
1790
1791fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
1792 match params.get(key) {
1793 None => Err(ExecutorError::MissingParam(key)),
1794 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
1795 }
1796}
1797
1798fn optional_validated<F>(
1802 params: &Value,
1803 key: &'static str,
1804 validator: F,
1805) -> Result<Option<String>, ExecutorError>
1806where
1807 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
1808{
1809 match params
1810 .get(key)
1811 .and_then(|v| v.as_str())
1812 .filter(|s| !s.is_empty())
1813 {
1814 Some(s) => Ok(Some(validator(s, key)?)),
1815 None => Ok(None),
1816 }
1817}
1818
1819fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
1829 params
1830 .get("username")
1831 .or_else(|| params.get("user"))
1832 .ok_or(ExecutorError::MissingParam("username"))
1833 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
1834}
1835
1836fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
1840 let id = require_str(params, "repo_id")?;
1841 let valid = !id.is_empty()
1842 && id
1843 .chars()
1844 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1845 if valid {
1846 Ok(id)
1847 } else {
1848 Err(ExecutorError::InvalidParam("repo_id"))
1849 }
1850}
1851
1852fn validated_no_newline<'a>(
1855 params: &'a Value,
1856 key: &'static str,
1857) -> Result<&'a str, ExecutorError> {
1858 let val = require_str(params, key)?;
1859 if val.contains('\n') || val.contains('\r') {
1860 Err(ExecutorError::InvalidParam(key))
1861 } else {
1862 Ok(val)
1863 }
1864}
1865
1866fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
1870 const MAX_LEN: usize = 8192;
1871 const ALLOWED_PREFIXES: &[&str] = &[
1872 "ssh-rsa",
1873 "ssh-ed25519",
1874 "ssh-ed25519-sk",
1875 "ecdsa-sha2-nistp256",
1876 "ecdsa-sha2-nistp384",
1877 "ecdsa-sha2-nistp521",
1878 "sk-ssh-ed25519",
1879 "sk-ecdsa-sha2-nistp256",
1880 ];
1881
1882 if s.is_empty() || s.len() > MAX_LEN {
1883 return Err(ExecutorError::InvalidParam("public_key"));
1884 }
1885 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
1886 return Err(ExecutorError::InvalidParam("public_key"));
1887 }
1888 if s.chars().any(|c| {
1903 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
1904 || !c.is_ascii()
1905 || c.is_ascii_control()
1906 }) {
1907 return Err(ExecutorError::InvalidParam("public_key"));
1908 }
1909 Ok(s.to_string())
1910}
1911
1912fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
1913 match params.get(key) {
1914 None => Err(ExecutorError::MissingParam(key)),
1915 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
1916 }
1917}
1918
1919fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1920 match params.get(key) {
1921 None => Err(ExecutorError::MissingParam(key)),
1922 Some(v) => {
1923 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
1924 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
1925 }
1926 }
1927}
1928
1929fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1933 let n = require_u32(params, key)?;
1934 if n == 0 {
1935 return Err(ExecutorError::InvalidParam(key));
1936 }
1937 Ok(n)
1938}
1939
1940fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
1944 match params.get(key) {
1945 None | Some(Value::Null) => Ok(vec![]),
1946 Some(Value::Array(arr)) => arr
1947 .iter()
1948 .map(|v| {
1949 v.as_str()
1950 .map(String::from)
1951 .ok_or(ExecutorError::InvalidParam(key))
1952 })
1953 .collect(),
1954 _ => Err(ExecutorError::InvalidParam(key)),
1955 }
1956}
1957
1958fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
1991 const BLOCKED_PREFIXES: &[&str] = &[
1992 "init=",
1993 "selinux=0",
1994 "enforcing=0",
1995 "security=",
1996 "module_blacklist=",
1997 "apparmor=0",
1998 "mitigations=off",
1999 "lockdown=",
2000 "pti=off",
2001 ];
2002 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
2003 const BLOCKED_UNIT_PREFIXES: &[&str] = &["emergency", "rescue", "single"];
2004
2005 let lower = arg.to_lowercase();
2006 let base = lower.split('=').next().unwrap_or(&lower);
2008
2009 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
2010 return Err(ExecutorError::InvalidParam(param));
2011 }
2012 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
2013 return Err(ExecutorError::InvalidParam(param));
2014 }
2015 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
2017 if BLOCKED_UNIT_PREFIXES
2018 .iter()
2019 .any(|u| unit_val.starts_with(u))
2020 {
2021 return Err(ExecutorError::InvalidParam(param));
2022 }
2023 }
2024 if BLOCKED_EXACT.contains(&base) {
2026 return Err(ExecutorError::InvalidParam(param));
2027 }
2028 Ok(())
2029}
2030
2031fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
2039 let normalized = s.trim().to_ascii_uppercase();
2040 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
2041 "TERM" => Ok("TERM"),
2042 "KILL" => Ok("KILL"),
2043 "HUP" => Ok("HUP"),
2044 "INT" => Ok("INT"),
2045 _ => Err(ExecutorError::InvalidParam("signal")),
2046 }
2047}
2048
2049pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
2059 match action_name {
2060 "UpdateSystem"
2061 | "InstallPackages"
2062 | "RemovePackages"
2063 | "RebaseSystem"
2064 | "SetKernelArguments"
2065 | "AddLayeredPackage"
2066 | "RemoveLayeredPackage"
2067 | "ReplaceLayeredPackage"
2068 | "ResetLayeredPackageOverride"
2069 | "RemoveBasePackage" => Some(ActionSpec {
2070 action_name: "RollbackDeployment",
2071 mechanism: ActionMechanism::Command {
2072 program: "rpm-ostree",
2073 args: vec!["rollback".to_string()],
2074 },
2075 risk_level: sysknife_types::RiskLevel::High,
2076 reboot_required: true,
2077 rollback_available: false,
2078 }),
2079 _ => None,
2080 }
2081}
2082
2083#[cfg(test)]
2084mod tests {
2085 use super::*;
2086 use serde_json::json;
2087 use sysknife_types::RiskLevel;
2088 use tempfile::tempdir;
2089
2090 #[test]
2093 fn build_spec_no_params_for_get_system_state() {
2094 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
2095 assert_eq!(spec.action_name, "GetSystemState");
2096 assert_eq!(spec.risk_level, RiskLevel::Low);
2097 }
2098
2099 #[test]
2100 fn build_spec_get_datetime_is_low_risk() {
2101 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
2102 assert_eq!(spec.action_name, "GetDateTime");
2103 assert_eq!(spec.risk_level, RiskLevel::Low);
2104 assert!(!spec.reboot_required);
2105 }
2106
2107 #[test]
2108 fn build_spec_unknown_action_returns_error() {
2109 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
2110 assert!(
2111 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
2112 "expected UnknownAction, got: {err}"
2113 );
2114 }
2115
2116 #[test]
2117 fn build_spec_missing_param_for_install_flatpak() {
2118 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
2120 assert!(
2121 matches!(err, ExecutorError::MissingParam("username")),
2122 "expected MissingParam(username), got: {err}"
2123 );
2124 }
2125
2126 #[test]
2130 fn build_spec_flatpak_accepts_user_alias() {
2131 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
2132 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2133 }
2134
2135 #[test]
2137 fn build_spec_resolve_username_prefers_explicit_username() {
2138 let spec = build_action_spec(
2139 "ListInstalledFlatpaks",
2140 &json!({ "username": "alice", "user": "bob" }),
2141 )
2142 .unwrap();
2143 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
2145 }
2146
2147 #[test]
2150 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
2151 let spec = build_action_spec(
2152 "InstallFlatpak",
2153 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
2154 )
2155 .unwrap();
2156 assert_eq!(
2157 spec.mechanism,
2158 ActionMechanism::Command {
2159 program: "sudo",
2160 args: vec![
2161 "runuser".to_string(),
2162 "-u".to_string(),
2163 "alice".to_string(),
2164 "--".to_string(),
2165 "flatpak".to_string(),
2166 "install".to_string(),
2167 "--user".to_string(),
2168 "-y".to_string(),
2169 "flathub".to_string(),
2170 "org.mozilla.firefox".to_string(),
2171 ],
2172 }
2173 );
2174 }
2175
2176 #[test]
2177 fn build_spec_install_flatpak_injects_app_and_remote() {
2178 let spec = build_action_spec(
2179 "InstallFlatpak",
2180 &json!({
2181 "username": "alice",
2182 "app_id": "org.mozilla.firefox",
2183 "remote": "flathub"
2184 }),
2185 )
2186 .unwrap();
2187 assert_eq!(spec.action_name, "InstallFlatpak");
2188 assert_eq!(
2189 spec.mechanism,
2190 ActionMechanism::Command {
2191 program: "sudo",
2192 args: vec![
2193 "runuser".to_string(),
2194 "-u".to_string(),
2195 "alice".to_string(),
2196 "--".to_string(),
2197 "flatpak".to_string(),
2198 "install".to_string(),
2199 "--user".to_string(),
2200 "-y".to_string(),
2201 "flathub".to_string(),
2202 "org.mozilla.firefox".to_string(),
2203 ],
2204 }
2205 );
2206 }
2207
2208 #[test]
2209 fn build_spec_pin_deployment_injects_index() {
2210 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2211 assert_eq!(
2212 spec.mechanism,
2213 ActionMechanism::Command {
2214 program: "sudo",
2215 args: vec![
2216 "ostree".to_string(),
2217 "admin".to_string(),
2218 "pin".to_string(),
2219 "1".to_string()
2220 ],
2221 }
2222 );
2223 }
2224
2225 #[test]
2226 fn build_spec_unpin_deployment_includes_unpin_flag() {
2227 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2228 assert_eq!(
2229 spec.mechanism,
2230 ActionMechanism::Command {
2231 program: "sudo",
2232 args: vec![
2233 "ostree".to_string(),
2234 "admin".to_string(),
2235 "pin".to_string(),
2236 "--unpin".to_string(),
2237 "2".to_string(),
2238 ],
2239 }
2240 );
2241 }
2242
2243 #[test]
2244 fn require_u32_rejects_overflow() {
2245 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2246 assert!(
2247 matches!(err, ExecutorError::InvalidParam("index")),
2248 "expected InvalidParam(index), got: {err}"
2249 );
2250 }
2251
2252 #[test]
2253 fn build_spec_rebase_system_injects_target_ref() {
2254 let spec = build_action_spec(
2255 "RebaseSystem",
2256 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
2257 )
2258 .unwrap();
2259 assert_eq!(
2260 spec.mechanism,
2261 ActionMechanism::Command {
2262 program: "sudo",
2263 args: vec![
2264 "rpm-ostree".to_string(),
2265 "rebase".to_string(),
2266 "fedora/41/x86_64/silverblue".to_string(),
2267 ],
2268 }
2269 );
2270 }
2271
2272 #[test]
2273 fn build_spec_set_kernel_arguments_appends_and_deletes() {
2274 let spec = build_action_spec(
2275 "SetKernelArguments",
2276 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
2277 )
2278 .unwrap();
2279 assert_eq!(
2280 spec.mechanism,
2281 ActionMechanism::Command {
2282 program: "sudo",
2283 args: vec![
2284 "rpm-ostree".to_string(),
2285 "kargs".to_string(),
2286 "--append=nomodeset".to_string(),
2287 "--delete=quiet".to_string(),
2288 ],
2289 }
2290 );
2291 }
2292
2293 #[test]
2294 fn build_spec_set_kernel_arguments_with_empty_arrays() {
2295 let spec =
2296 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
2297 assert_eq!(
2298 spec.mechanism,
2299 ActionMechanism::Command {
2300 program: "sudo",
2301 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2302 }
2303 );
2304 }
2305
2306 #[test]
2307 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
2308 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
2309 assert_eq!(
2310 spec.mechanism,
2311 ActionMechanism::Command {
2312 program: "sudo",
2313 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2314 }
2315 );
2316 }
2317
2318 #[test]
2321 fn delete_user_rejects_critical_account() {
2322 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
2323 assert!(
2324 matches!(err, ExecutorError::InvalidParam("username")),
2325 "expected InvalidParam(username), got: {err}"
2326 );
2327 }
2328
2329 #[test]
2330 fn delete_group_rejects_critical_group() {
2331 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
2332 assert!(
2333 matches!(err, ExecutorError::InvalidParam("group")),
2334 "expected InvalidParam(group), got: {err}"
2335 );
2336 }
2337
2338 #[test]
2339 fn lock_user_account_rejects_critical_account() {
2340 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
2341 assert!(
2342 matches!(err, ExecutorError::InvalidParam("username")),
2343 "expected InvalidParam(username), got: {err}"
2344 );
2345 }
2346
2347 #[test]
2348 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
2349 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
2350 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
2351 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
2352 }
2353
2354 #[test]
2357 fn build_spec_add_package_repository_rejects_path_traversal() {
2358 let err = build_action_spec(
2359 "AddPackageRepository",
2360 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
2361 )
2362 .unwrap_err();
2363 assert!(
2364 matches!(err, ExecutorError::InvalidParam("repo_id")),
2365 "expected InvalidParam(repo_id), got: {err}"
2366 );
2367 }
2368
2369 #[test]
2370 fn build_spec_add_package_repository_rejects_newline_in_url() {
2371 let err = build_action_spec(
2372 "AddPackageRepository",
2373 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2374 )
2375 .unwrap_err();
2376 assert!(
2377 matches!(err, ExecutorError::InvalidParam("repo_url")),
2378 "expected InvalidParam(repo_url), got: {err}"
2379 );
2380 }
2381
2382 #[test]
2383 fn build_spec_add_package_repository_accepts_valid_repo_id() {
2384 let spec = build_action_spec(
2385 "AddPackageRepository",
2386 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2387 )
2388 .unwrap();
2389 assert_eq!(spec.action_name, "AddPackageRepository");
2390 }
2391
2392 #[test]
2393 fn build_spec_remove_package_repository_rejects_path_traversal() {
2394 let err = build_action_spec(
2395 "RemovePackageRepository",
2396 &json!({ "repo_id": "../../etc/passwd" }),
2397 )
2398 .unwrap_err();
2399 assert!(
2400 matches!(err, ExecutorError::InvalidParam("repo_id")),
2401 "expected InvalidParam(repo_id), got: {err}"
2402 );
2403 }
2404
2405 #[tokio::test]
2406 async fn execute_spec_command_captures_stdout() {
2407 let spec = ActionSpec {
2408 action_name: "GetSystemState",
2409 mechanism: ActionMechanism::Command {
2410 program: "echo",
2411 args: vec!["hello".to_string()],
2412 },
2413 risk_level: RiskLevel::Low,
2414 reboot_required: false,
2415 rollback_available: false,
2416 };
2417 let out = execute_spec(&spec).await.unwrap();
2418 assert_eq!(out.stdout.trim(), "hello");
2419 assert_eq!(out.exit_code, 0);
2420 }
2421
2422 #[test]
2430 fn action_not_stopped_names_the_group_and_the_timeout() {
2431 let err = ExecutorError::ActionNotStopped {
2432 program: "rpm-ostree".to_string(),
2433 pgid: 4242,
2434 timeout_secs: 7200,
2435 };
2436 let msg = err.to_string();
2437 assert!(msg.contains("4242"), "must name the pgid to inspect: {msg}");
2438 assert!(msg.contains("7200"), "must name the timeout: {msg}");
2439 assert!(
2440 msg.contains("could not be confirmed stopped"),
2441 "must say what happened: {msg}"
2442 );
2443 assert!(
2444 msg.contains("rollback was skipped"),
2445 "must say the rollback was skipped: {msg}"
2446 );
2447 }
2448
2449 #[tokio::test]
2454 async fn kill_and_reap_confirms_a_killable_group_stopped_and_returns_bounded() {
2455 let mut child = tokio::process::Command::new("sh")
2456 .args(["-c", "sleep 3600 & wait"])
2457 .process_group(0)
2458 .stdin(Stdio::null())
2459 .stdout(Stdio::null())
2460 .stderr(Stdio::null())
2461 .spawn()
2462 .expect("spawn");
2463 let started = tokio::time::Instant::now();
2464 let err = kill_and_reap(&mut child, "sh").await;
2465 let elapsed = started.elapsed();
2466
2467 assert!(
2468 matches!(&err, ExecutorError::Io(e) if e.kind() == io::ErrorKind::TimedOut),
2469 "a killable group must report a plain stopped-timeout, got: {err:?}"
2470 );
2471 assert!(
2472 elapsed < Duration::from_secs(GROUP_TERM_GRACE.as_secs() + 3),
2473 "must return promptly, took {elapsed:?}"
2474 );
2475 }
2476
2477 #[tokio::test]
2480 async fn real_executor_streams_each_stdout_line() {
2481 let spec = ActionSpec {
2482 action_name: "GetSystemState",
2483 mechanism: ActionMechanism::Command {
2484 program: "printf",
2485 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2486 },
2487 risk_level: RiskLevel::Low,
2488 reboot_required: false,
2489 rollback_available: false,
2490 };
2491 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2492 let out = RealActionExecutor
2493 .execute_with_progress(&spec, tx)
2494 .await
2495 .unwrap();
2496
2497 let mut lines = Vec::new();
2498 while let Ok(line) = rx.try_recv() {
2499 lines.push(line);
2500 }
2501 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2502 assert_eq!(out.exit_code, 0);
2503 assert!(out.stdout.contains("line-two"));
2504 }
2505
2506 #[tokio::test]
2507 async fn execute_spec_file_write_creates_file() {
2508 let dir = tempdir().unwrap();
2509 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2510 let spec = ActionSpec {
2511 action_name: "AddPackageRepository",
2512 mechanism: ActionMechanism::FileWrite {
2513 path: path.clone(),
2514 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2515 },
2516 risk_level: RiskLevel::Medium,
2517 reboot_required: false,
2518 rollback_available: true,
2519 };
2520 let out = execute_spec(&spec).await.unwrap();
2521 assert_eq!(out.exit_code, 0);
2522 assert_eq!(
2523 std::fs::read_to_string(&path).unwrap(),
2524 "[repo]\nbaseurl=https://example.test\n"
2525 );
2526 }
2527
2528 #[tokio::test]
2529 async fn execute_spec_file_patch_replaces_first_occurrence() {
2530 let dir = tempdir().unwrap();
2531 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2532 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2533 let spec = ActionSpec {
2534 action_name: "EnablePackageRepository",
2535 mechanism: ActionMechanism::FilePatch {
2536 path: path.clone(),
2537 search: "enabled=0".to_string(),
2538 replace: "enabled=1".to_string(),
2539 },
2540 risk_level: RiskLevel::Medium,
2541 reboot_required: false,
2542 rollback_available: true,
2543 };
2544 execute_spec(&spec).await.unwrap();
2545 assert_eq!(
2546 std::fs::read_to_string(&path).unwrap(),
2547 "[myrepo]\nenabled=1\n"
2548 );
2549 }
2550
2551 #[tokio::test]
2552 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2553 let dir = tempdir().unwrap();
2554 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2555 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2556 let spec = ActionSpec {
2557 action_name: "EnablePackageRepository",
2558 mechanism: ActionMechanism::FilePatch {
2559 path: path.clone(),
2560 search: "enabled=0".to_string(),
2561 replace: "enabled=1".to_string(),
2562 },
2563 risk_level: RiskLevel::Medium,
2564 reboot_required: false,
2565 rollback_available: true,
2566 };
2567 let out = execute_spec(&spec).await.unwrap();
2568 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2569 assert!(
2570 out.stderr.contains("search string not found in file"),
2571 "stderr should explain the failure: {}",
2572 out.stderr
2573 );
2574 assert_eq!(
2576 std::fs::read_to_string(&path).unwrap(),
2577 "[myrepo]\nenabled=1\n"
2578 );
2579 }
2580
2581 #[tokio::test]
2582 async fn execute_spec_file_patch_allows_empty_search_string() {
2583 let dir = tempdir().unwrap();
2586 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
2587 std::fs::write(&path, "hello").unwrap();
2588 let spec = ActionSpec {
2589 action_name: "Test",
2590 mechanism: ActionMechanism::FilePatch {
2591 path: path.clone(),
2592 search: String::new(),
2593 replace: "prefix-".to_string(),
2594 },
2595 risk_level: RiskLevel::Low,
2596 reboot_required: false,
2597 rollback_available: false,
2598 };
2599 let out = execute_spec(&spec).await.unwrap();
2600 assert_eq!(out.exit_code, 0);
2601 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
2602 }
2603
2604 #[tokio::test]
2605 async fn execute_spec_file_delete_removes_file() {
2606 let dir = tempdir().unwrap();
2607 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2608 std::fs::write(&path, "[myrepo]\n").unwrap();
2609 let spec = ActionSpec {
2610 action_name: "RemovePackageRepository",
2611 mechanism: ActionMechanism::FileDelete { path: path.clone() },
2612 risk_level: RiskLevel::Medium,
2613 reboot_required: false,
2614 rollback_available: true,
2615 };
2616 execute_spec(&spec).await.unwrap();
2617 assert!(!std::path::Path::new(&path).exists());
2618 }
2619
2620 #[tokio::test]
2621 async fn execute_spec_file_scan_lists_directory_entries() {
2622 let dir = tempdir().unwrap();
2623 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
2624 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
2625 let spec = ActionSpec {
2626 action_name: "ListPackageRepositories",
2627 mechanism: ActionMechanism::FileScan {
2628 path: dir.path().to_string_lossy().into_owned(),
2629 },
2630 risk_level: RiskLevel::Low,
2631 reboot_required: false,
2632 rollback_available: false,
2633 };
2634 let out = execute_spec(&spec).await.unwrap();
2635 assert!(
2636 out.stdout.contains("a.repo"),
2637 "expected a.repo in: {}",
2638 out.stdout
2639 );
2640 assert!(
2641 out.stdout.contains("b.repo"),
2642 "expected b.repo in: {}",
2643 out.stdout
2644 );
2645 assert_eq!(out.exit_code, 0);
2646 }
2647
2648 #[test]
2651 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
2652 let spec = rollback_spec_for("UpdateSystem").unwrap();
2653 assert_eq!(spec.action_name, "RollbackDeployment");
2654 assert!(
2655 matches!(
2656 &spec.mechanism,
2657 ActionMechanism::Command { program: "rpm-ostree", args }
2658 if args == &["rollback".to_string()]
2659 ),
2660 "expected rpm-ostree rollback, got: {:?}",
2661 spec.mechanism
2662 );
2663 assert!(!spec.rollback_available, "rollback spec must not recurse");
2664 }
2665
2666 #[test]
2667 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
2668 assert!(rollback_spec_for("InstallPackages").is_some());
2669 }
2670
2671 #[test]
2672 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
2673 assert!(rollback_spec_for("RemovePackages").is_some());
2674 }
2675
2676 #[test]
2677 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
2678 assert!(rollback_spec_for("RebaseSystem").is_some());
2679 }
2680
2681 #[test]
2682 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
2683 assert!(rollback_spec_for("SetKernelArguments").is_some());
2684 }
2685
2686 #[test]
2687 fn rollback_spec_for_read_only_action_returns_none() {
2688 assert!(rollback_spec_for("GetSystemState").is_none());
2689 assert!(rollback_spec_for("ListUsers").is_none());
2690 assert!(rollback_spec_for("GetFirewallState").is_none());
2691 }
2692
2693 #[test]
2694 fn rollback_spec_for_non_rollbackable_actions_return_none() {
2695 assert!(rollback_spec_for("AddUserToGroup").is_none());
2696 assert!(rollback_spec_for("DeleteUser").is_none());
2697 assert!(rollback_spec_for("CleanupDeployments").is_none());
2698 assert!(rollback_spec_for("RollbackDeployment").is_none());
2700 }
2701
2702 #[test]
2705 fn public_key_accepts_valid_ed25519() {
2706 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
2707 assert!(validated_public_key(key).is_ok());
2708 }
2709
2710 #[test]
2711 fn public_key_accepts_valid_rsa() {
2712 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
2713 assert!(validated_public_key(key).is_ok());
2714 }
2715
2716 #[test]
2717 fn public_key_rejects_empty() {
2718 assert!(matches!(
2719 validated_public_key(""),
2720 Err(ExecutorError::InvalidParam("public_key"))
2721 ));
2722 }
2723
2724 #[test]
2725 fn public_key_rejects_unknown_prefix() {
2726 assert!(matches!(
2727 validated_public_key("sk-rsa AAAA... user@host"),
2728 Err(ExecutorError::InvalidParam("public_key"))
2729 ));
2730 assert!(matches!(
2731 validated_public_key("AAAAB3Nz... user@host"),
2732 Err(ExecutorError::InvalidParam("public_key"))
2733 ));
2734 }
2735
2736 #[test]
2737 fn public_key_rejects_single_quote() {
2738 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
2739 assert!(matches!(
2740 validated_public_key(key),
2741 Err(ExecutorError::InvalidParam("public_key"))
2742 ));
2743 }
2744
2745 #[test]
2753 fn package_actions_reject_a_local_file_target() {
2754 let reject: &[(&str, serde_json::Value)] = &[
2755 ("AptInstall", json!({"package": "/tmp/evil.deb"})),
2756 ("AptRemove", json!({"package": "/tmp/evil.deb"})),
2757 ("AptPurge", json!({"package": "./evil.deb"})),
2758 ("AptHold", json!({"package": "/tmp/evil.deb"})),
2759 ("AptUnhold", json!({"package": "/tmp/evil.deb"})),
2760 ("AddLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
2761 ("RemoveLayeredPackage", json!({"package": "/tmp/evil.rpm"})),
2762 ("RemoveBasePackage", json!({"package": "/tmp/evil.rpm"})),
2763 (
2764 "ReplaceLayeredPackage",
2765 json!({"old": "/tmp/evil.rpm", "new": "nginx"}),
2766 ),
2767 (
2768 "ReplaceLayeredPackage",
2769 json!({"old": "nginx", "new": "/tmp/evil.rpm"}),
2770 ),
2771 ("InstallPackages", json!({"packages": ["/tmp/evil.rpm"]})),
2772 ("RemovePackages", json!({"packages": ["/tmp/evil.rpm"]})),
2773 ];
2774 for (action, params) in reject {
2775 assert!(
2776 matches!(
2777 build_action_spec(action, params),
2778 Err(ExecutorError::InvalidParam(_))
2779 ),
2780 "{action} must refuse a local-file package target: {params}"
2781 );
2782 }
2783 let accept: &[(&str, serde_json::Value)] = &[
2786 ("AptInstall", json!({"package": "nginx"})),
2787 ("AddLayeredPackage", json!({"package": "nginx=1.24.0-1"})),
2788 (
2789 "InstallPackages",
2790 json!({"packages": ["nginx", "python3-pip"]}),
2791 ),
2792 ];
2793 for (action, params) in accept {
2794 assert!(
2795 build_action_spec(action, params).is_ok(),
2796 "{action} must accept a real package spec: {params}"
2797 );
2798 }
2799 }
2800
2801 #[test]
2806 fn activating_actions_refuse_a_root_shell_unit() {
2807 for action in ["StartService", "RestartService", "UnmaskService"] {
2808 for unit in [
2809 "debug-shell.service",
2810 "emergency.target",
2811 "rescue.service",
2812 "runlevel1.target",
2813 ] {
2814 assert!(
2815 matches!(
2816 build_action_spec(action, &json!({"unit": unit})),
2817 Err(ExecutorError::InvalidParam("unit"))
2818 ),
2819 "{action} must not activate {unit}"
2820 );
2821 }
2822 assert!(
2823 build_action_spec(action, &json!({"unit": "nginx.service"})).is_ok(),
2824 "{action} must still activate an ordinary unit"
2825 );
2826 }
2827 assert!(
2828 build_action_spec(
2829 "SetServiceEnabled",
2830 &json!({"unit": "debug-shell.service", "enabled": true})
2831 )
2832 .is_err(),
2833 "enabling a root-shell unit brings it up at boot and must be refused"
2834 );
2835 assert!(
2836 build_action_spec(
2837 "SetServiceEnabled",
2838 &json!({"unit": "debug-shell.service", "enabled": false})
2839 )
2840 .is_ok(),
2841 "disabling a root-shell unit is a mitigation and must stay reachable"
2842 );
2843 for action in ["StopService", "MaskService"] {
2846 assert!(
2847 build_action_spec(action, &json!({"unit": "debug-shell.service"})).is_ok(),
2848 "{action} on debug-shell is a mitigation and must stay allowed"
2849 );
2850 }
2851 }
2852
2853 #[test]
2854 fn authorized_keys_actions_reject_a_traversal_username() {
2855 for action in [
2862 "GetAuthorizedKeys",
2863 "AddAuthorizedKey",
2864 "RemoveAuthorizedKey",
2865 ] {
2866 for bad in ["../../etc", "..", "root/../../etc"] {
2867 let err = build_action_spec(
2868 action,
2869 &json!({
2870 "username": bad,
2871 "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h",
2872 }),
2873 )
2874 .unwrap_err();
2875 assert!(
2876 matches!(err, ExecutorError::InvalidParam("username")),
2877 "{action} must reject username {bad:?}, got {err:?}"
2878 );
2879 }
2880 }
2881 }
2882
2883 #[test]
2884 fn ip_taking_actions_reject_a_malformed_address() {
2885 let cases: [(&str, serde_json::Value); 3] = [
2888 (
2889 "ResolvectlSetDns",
2890 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
2891 ),
2892 (
2893 "Fail2banBanIp",
2894 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
2895 ),
2896 (
2897 "Fail2banUnbanIp",
2898 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
2899 ),
2900 ];
2901 for (action, params) in cases {
2902 let err = build_action_spec(action, ¶ms).unwrap_err();
2903 assert!(
2904 matches!(err, ExecutorError::InvalidIpAddress { .. }),
2905 "{action} must reject a malformed IP, got {err:?}"
2906 );
2907 }
2908 }
2909
2910 #[test]
2911 fn public_key_rejects_pipe_metacharacter() {
2912 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
2916 assert!(matches!(
2917 validated_public_key(key),
2918 Err(ExecutorError::InvalidParam("public_key"))
2919 ));
2920 }
2921
2922 #[test]
2923 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
2924 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
2931 assert!(validated_public_key(key).is_ok());
2932 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
2935 }
2936
2937 #[test]
2938 fn public_key_rejects_shell_metacharacters() {
2939 for (metachar, desc) in [
2940 (';', "semicolon"),
2941 ('`', "backtick"),
2942 ('$', "dollar"),
2943 ('\\', "backslash"),
2944 ('&', "ampersand"),
2945 ] {
2946 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
2947 assert!(
2948 matches!(
2949 validated_public_key(&key),
2950 Err(ExecutorError::InvalidParam("public_key"))
2951 ),
2952 "{desc} should be rejected"
2953 );
2954 }
2955 }
2956
2957 #[test]
2958 fn public_key_rejects_newline() {
2959 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
2960 assert!(matches!(
2961 validated_public_key(key),
2962 Err(ExecutorError::InvalidParam("public_key"))
2963 ));
2964 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
2965 assert!(matches!(
2966 validated_public_key(key_cr),
2967 Err(ExecutorError::InvalidParam("public_key"))
2968 ));
2969 }
2970
2971 #[test]
2972 fn public_key_rejects_too_long() {
2973 let long_payload = "A".repeat(8192);
2975 let key = format!("ssh-ed25519 {long_payload} user@host");
2976 assert!(matches!(
2977 validated_public_key(&key),
2978 Err(ExecutorError::InvalidParam("public_key"))
2979 ));
2980 }
2981
2982 #[test]
2985 fn str_array_or_empty_rejects_non_string_element() {
2986 let params = json!({ "packages": ["vim", 42, "curl"] });
2987 assert!(matches!(
2988 str_array_or_empty(¶ms, "packages"),
2989 Err(ExecutorError::InvalidParam("packages"))
2990 ));
2991 }
2992
2993 #[test]
2994 fn str_array_or_empty_accepts_string_array() {
2995 let params = json!({ "packages": ["vim", "curl"] });
2996 assert_eq!(
2997 str_array_or_empty(¶ms, "packages").unwrap(),
2998 vec!["vim".to_string(), "curl".to_string()]
2999 );
3000 }
3001
3002 #[test]
3003 fn str_array_or_empty_returns_empty_when_key_absent() {
3004 let params = json!({});
3005 assert_eq!(
3006 str_array_or_empty(¶ms, "packages").unwrap(),
3007 Vec::<String>::new()
3008 );
3009 }
3010
3011 #[test]
3014 fn kernel_arg_allows_safe_args() {
3015 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
3016 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
3017 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
3018 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
3019 }
3020
3021 #[test]
3022 fn kernel_arg_blocks_init_override() {
3023 assert!(matches!(
3024 validated_safe_kernel_arg("init=/bin/sh", "add"),
3025 Err(ExecutorError::InvalidParam("add"))
3026 ));
3027 assert!(matches!(
3028 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
3029 Err(ExecutorError::InvalidParam("add"))
3030 ));
3031 }
3032
3033 #[test]
3034 fn kernel_arg_blocks_selinux_disable() {
3035 assert!(matches!(
3036 validated_safe_kernel_arg("selinux=0", "add"),
3037 Err(ExecutorError::InvalidParam("add"))
3038 ));
3039 assert!(matches!(
3040 validated_safe_kernel_arg("enforcing=0", "add"),
3041 Err(ExecutorError::InvalidParam("add"))
3042 ));
3043 }
3044
3045 #[test]
3046 fn kernel_arg_blocks_security_override() {
3047 assert!(matches!(
3048 validated_safe_kernel_arg("security=none", "add"),
3049 Err(ExecutorError::InvalidParam("add"))
3050 ));
3051 }
3052
3053 #[test]
3054 fn kernel_arg_blocks_module_blacklist() {
3055 assert!(matches!(
3056 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
3057 Err(ExecutorError::InvalidParam("add"))
3058 ));
3059 }
3060
3061 #[test]
3062 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
3063 assert!(matches!(
3064 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
3065 Err(ExecutorError::InvalidParam("add"))
3066 ));
3067 assert!(matches!(
3068 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
3069 Err(ExecutorError::InvalidParam("add"))
3070 ));
3071 assert!(matches!(
3072 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
3073 Err(ExecutorError::InvalidParam("add"))
3074 ));
3075 }
3076
3077 #[test]
3078 fn kernel_arg_blocks_apparmor_disable() {
3079 assert!(matches!(
3080 validated_safe_kernel_arg("apparmor=0", "add"),
3081 Err(ExecutorError::InvalidParam("add"))
3082 ));
3083 }
3084
3085 #[test]
3086 fn kernel_arg_blocks_mitigations_off() {
3087 assert!(matches!(
3088 validated_safe_kernel_arg("mitigations=off", "add"),
3089 Err(ExecutorError::InvalidParam("add"))
3090 ));
3091 }
3092
3093 #[test]
3094 fn kernel_arg_blocks_lockdown_override() {
3095 assert!(matches!(
3096 validated_safe_kernel_arg("lockdown=none", "add"),
3097 Err(ExecutorError::InvalidParam("add"))
3098 ));
3099 }
3100
3101 #[test]
3102 fn kernel_arg_blocks_pti_off() {
3103 assert!(matches!(
3104 validated_safe_kernel_arg("pti=off", "add"),
3105 Err(ExecutorError::InvalidParam("add"))
3106 ));
3107 }
3108
3109 #[test]
3110 fn kernel_arg_blocks_smap_smep_disable() {
3111 assert!(matches!(
3112 validated_safe_kernel_arg("nosmap", "add"),
3113 Err(ExecutorError::InvalidParam("add"))
3114 ));
3115 assert!(matches!(
3116 validated_safe_kernel_arg("nosmep", "add"),
3117 Err(ExecutorError::InvalidParam("add"))
3118 ));
3119 }
3120
3121 #[test]
3122 fn kernel_arg_blocks_single_user_shortcuts() {
3123 assert!(matches!(
3125 validated_safe_kernel_arg("single", "add"),
3126 Err(ExecutorError::InvalidParam("add"))
3127 ));
3128 assert!(matches!(
3129 validated_safe_kernel_arg("s", "add"),
3130 Err(ExecutorError::InvalidParam("add"))
3131 ));
3132 assert!(matches!(
3133 validated_safe_kernel_arg("1", "add"),
3134 Err(ExecutorError::InvalidParam("add"))
3135 ));
3136 }
3137
3138 #[test]
3139 fn signal_process_guardrails() {
3140 for bad_pid in [0, 1] {
3142 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
3143 assert!(
3144 matches!(err, ExecutorError::InvalidParam("pid")),
3145 "pid {bad_pid} must be rejected, got {err:?}"
3146 );
3147 }
3148 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
3150 .unwrap_err();
3151 assert!(
3152 matches!(err, ExecutorError::InvalidParam("signal")),
3153 "got {err:?}"
3154 );
3155 assert!(matches!(
3157 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
3158 ExecutorError::MissingParam("pid")
3159 ));
3160 let spec = build_action_spec(
3162 "SignalProcess",
3163 &json!({ "pid": "4242", "signal": "sigkill" }),
3164 )
3165 .unwrap();
3166 assert_eq!(spec.action_name, "SignalProcess");
3167 assert_eq!(
3168 spec.mechanism,
3169 ActionMechanism::Command {
3170 program: "sudo",
3171 args: vec![
3172 "kill".to_string(),
3173 "-s".to_string(),
3174 "KILL".to_string(),
3175 "4242".to_string()
3176 ],
3177 }
3178 );
3179 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
3181 if let ActionMechanism::Command { args, .. } = spec.mechanism {
3182 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
3183 } else {
3184 panic!("expected Command mechanism");
3185 }
3186 }
3187
3188 #[test]
3189 fn create_scheduled_job_validates_name_command_schedule() {
3190 assert!(matches!(
3192 build_action_spec(
3193 "CreateScheduledJob",
3194 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
3195 )
3196 .unwrap_err(),
3197 ExecutorError::InvalidParam("command")
3198 ));
3199 assert!(matches!(
3201 build_action_spec(
3202 "CreateScheduledJob",
3203 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
3204 )
3205 .unwrap_err(),
3206 ExecutorError::InvalidParam("name")
3207 ));
3208 assert!(matches!(
3210 build_action_spec(
3211 "CreateScheduledJob",
3212 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
3213 )
3214 .unwrap_err(),
3215 ExecutorError::InvalidParam("schedule")
3216 ));
3217 let spec = build_action_spec(
3219 "CreateScheduledJob",
3220 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
3221 )
3222 .unwrap();
3223 assert_eq!(
3224 spec.mechanism,
3225 ActionMechanism::Command {
3226 program: "sudo",
3227 args: vec![
3228 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
3229 "--name".to_string(),
3230 "nightly-backup".to_string(),
3231 "--command".to_string(),
3232 "/usr/bin/backup --full".to_string(),
3233 "--schedule".to_string(),
3234 "*-*-* 02:00:00".to_string(),
3235 ],
3236 }
3237 );
3238 }
3239
3240 #[test]
3241 fn set_sshd_option_allowlist_guardrails() {
3242 assert!(matches!(
3244 build_action_spec(
3245 "SetSshdOption",
3246 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
3247 )
3248 .unwrap_err(),
3249 ExecutorError::InvalidParam("option")
3250 ));
3251 assert!(matches!(
3253 build_action_spec(
3254 "SetSshdOption",
3255 &json!({ "option": "PermitRootLogin", "value": "maybe" })
3256 )
3257 .unwrap_err(),
3258 ExecutorError::InvalidParam("value")
3259 ));
3260 let spec = build_action_spec(
3262 "SetSshdOption",
3263 &json!({ "option": "PasswordAuthentication", "value": "no" }),
3264 )
3265 .unwrap();
3266 assert_eq!(
3267 spec.mechanism,
3268 ActionMechanism::Command {
3269 program: "sudo",
3270 args: vec![
3271 "/usr/lib/sysknife/sshd-option-edit".to_string(),
3272 "--option".to_string(),
3273 "PasswordAuthentication".to_string(),
3274 "--value".to_string(),
3275 "no".to_string(),
3276 ],
3277 }
3278 );
3279 }
3280
3281 #[test]
3282 fn configure_unattended_upgrades_toggles_helper_flag() {
3283 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
3284 let spec = build_action_spec(
3285 "ConfigureUnattendedUpgrades",
3286 &json!({ "enabled": enabled }),
3287 )
3288 .unwrap();
3289 assert_eq!(
3290 spec.mechanism,
3291 ActionMechanism::Command {
3292 program: "sudo",
3293 args: vec![
3294 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
3295 flag.to_string(),
3296 ],
3297 }
3298 );
3299 }
3300 assert!(matches!(
3301 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
3302 ExecutorError::MissingParam("enabled")
3303 ));
3304 }
3305
3306 #[test]
3307 fn grub_set_kargs_append_blocks_single_user_shortcut() {
3308 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
3316 let err = build_action_spec(
3317 "GrubSetKargs",
3318 &json!({ "append": [dangerous], "delete": [] }),
3319 )
3320 .unwrap_err();
3321 assert!(
3322 matches!(err, ExecutorError::InvalidParam("append")),
3323 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
3324 );
3325 }
3326 assert!(build_action_spec(
3328 "GrubSetKargs",
3329 &json!({ "append": ["quiet"], "delete": [] })
3330 )
3331 .is_ok());
3332 }
3333
3334 #[test]
3335 fn kernel_arg_build_spec_rejects_dangerous_arg() {
3336 let err = build_action_spec(
3342 "SetKernelArguments",
3343 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
3344 )
3345 .unwrap_err();
3346 assert!(
3347 matches!(err, ExecutorError::InvalidParam("add")),
3348 "expected InvalidParam(add), got {err:?}"
3349 );
3350 }
3351
3352 #[test]
3353 fn grub_set_kargs_cannot_delete_a_protective_karg() {
3354 for karg in [
3361 "apparmor=1",
3362 "lockdown=confidentiality",
3363 "mitigations=auto",
3364 "pti=on",
3365 "selinux=1",
3366 ] {
3367 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
3368 .unwrap_err();
3369 assert!(
3370 matches!(err, ExecutorError::InvalidParam("delete")),
3371 "deleting {karg:?} must be refused, got {err:?}"
3372 );
3373 }
3374 }
3375
3376 #[test]
3381 fn rollback_available_matches_rollback_spec_for_all_actions() {
3382 for spec in crate::actions::all_specs() {
3390 let has_rollback = rollback_spec_for(spec.action_name).is_some();
3391 assert_eq!(
3392 spec.rollback_available,
3393 has_rollback,
3394 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
3395 spec.action_name,
3396 spec.rollback_available,
3397 if has_rollback { "Some" } else { "None" },
3398 );
3399 }
3400 }
3401
3402 #[test]
3408 fn create_user_is_high_risk() {
3409 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
3410 assert_eq!(
3411 spec.risk_level,
3412 RiskLevel::High,
3413 "CreateUser must be High (T1136.001 Persistence)"
3414 );
3415 }
3416
3417 #[test]
3418 fn configure_firewall_is_high_risk() {
3419 let spec = build_action_spec(
3420 "ConfigureFirewall",
3421 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
3422 )
3423 .unwrap();
3424 assert_eq!(
3425 spec.risk_level,
3426 RiskLevel::High,
3427 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
3428 );
3429 }
3430
3431 #[test]
3432 fn mask_service_is_high_risk() {
3433 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
3434 assert_eq!(
3435 spec.risk_level,
3436 RiskLevel::High,
3437 "MaskService must be High (T1562.001 Impair Defenses)"
3438 );
3439 }
3440
3441 #[test]
3442 fn add_package_repository_is_high_risk() {
3443 let spec = build_action_spec(
3444 "AddPackageRepository",
3445 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
3446 )
3447 .unwrap();
3448 assert_eq!(
3449 spec.risk_level,
3450 RiskLevel::High,
3451 "AddPackageRepository must be High (supply-chain vector)"
3452 );
3453 }
3454
3455 #[test]
3456 fn set_dns_servers_is_high_risk() {
3457 let spec = build_action_spec(
3458 "SetDnsServers",
3459 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
3460 )
3461 .unwrap();
3462 assert_eq!(
3463 spec.risk_level,
3464 RiskLevel::High,
3465 "SetDnsServers must be High (DNS hijacking / T1557)"
3466 );
3467 }
3468}