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_apparmor_profile, validated_apt_package, validated_apt_pin_expr,
9 validated_apt_pin_name, validated_audit_path, validated_audit_perms, validated_cpu_quota,
10 validated_domain, validated_email, validated_fstype, validated_group,
11 validated_group_not_critical, validated_hostname, validated_journal_grep,
12 validated_journal_priority, validated_journal_time, validated_locale, validated_log_path,
13 validated_lvm_name, validated_lvm_size, validated_memory_limit, validated_mount_device,
14 validated_mount_options, validated_mount_point, validated_port_or_service,
15 validated_ppa_name, validated_pro_service, validated_safe_arg, validated_sudo_commands,
16 validated_sudoers_name, validated_swap_path, validated_sysctl_key, validated_sysctl_value,
17 validated_syslog_host, validated_tasks_max, validated_timezone, validated_unit_name,
18 validated_username, validated_username_not_critical,
19 },
20 ActionMechanism, ActionSpec,
21};
22use async_trait::async_trait;
23use serde_json::Value;
24use std::io;
25use std::net::IpAddr;
26use std::process::Stdio;
27use std::str::FromStr;
28use std::time::Duration;
29use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
30use tokio::sync::mpsc::UnboundedSender;
31
32const MAX_SCHEDULED_COMMAND_LEN: usize = 512;
43const MAX_SCHEDULE_EXPR_LEN: usize = 128;
45const MAX_JOURNAL_LINES: u64 = 10_000;
48const MAX_SWAP_SIZE_MB: u32 = 1_048_576;
50const MAX_PASSWORD_AGE_DAYS: u64 = 99_999;
52const MAX_PASSWORD_MINLEN: u64 = 128;
54const MAX_LOCKOUT_WINDOW_SECS: u64 = 604_800;
56const MAX_FAIL2BAN_WINDOW_SECS: u64 = 2_592_000;
58
59#[derive(Debug, thiserror::Error)]
60pub enum ExecutorError {
61 #[error("unknown action: {0}")]
62 UnknownAction(String),
63
64 #[error("missing required param: {0}")]
65 MissingParam(&'static str),
66
67 #[error("invalid param type for: {0}")]
68 InvalidParam(&'static str),
69
70 #[error("invalid IP address for param '{param}': '{value}'")]
76 InvalidIpAddress { param: &'static str, value: String },
77
78 #[error("io error: {0}")]
79 Io(#[from] io::Error),
80}
81
82#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ExecutionOutput {
92 pub stdout: String,
93 pub stderr: String,
94 pub exit_code: i32,
95}
96
97impl ExecutionOutput {
98 pub fn is_success(&self) -> bool {
100 self.exit_code == 0
101 }
102
103 pub fn is_nonzero(&self) -> bool {
105 self.exit_code != 0
106 }
107}
108
109#[async_trait]
116pub trait ActionExecutor: Send + Sync {
117 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
119
120 async fn execute_with_progress(
126 &self,
127 spec: &ActionSpec,
128 progress: UnboundedSender<String>,
129 ) -> Result<ExecutionOutput, ExecutorError> {
130 let output = self.execute(spec).await?;
131 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
132 let _ = progress.send(line.to_string());
133 }
134 Ok(output)
135 }
136}
137
138pub struct RealActionExecutor;
140
141#[async_trait]
142impl ActionExecutor for RealActionExecutor {
143 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
144 execute_spec(spec).await
145 }
146
147 async fn execute_with_progress(
148 &self,
149 spec: &ActionSpec,
150 progress: UnboundedSender<String>,
151 ) -> Result<ExecutionOutput, ExecutorError> {
152 match &spec.mechanism {
153 ActionMechanism::Command { program, args } => {
154 execute_command_with_progress(program, args, progress).await
155 }
156 _ => execute_spec(spec).await,
157 }
158 }
159}
160
161const DEFAULT_ACTION_TIMEOUT: Duration = Duration::from_secs(2 * 60 * 60);
175
176fn action_timeout() -> Duration {
177 match std::env::var("SYSKNIFE_ACTION_TIMEOUT_SECS") {
178 Ok(raw) => match raw.trim().parse::<u64>() {
179 Ok(secs) if secs > 0 => Duration::from_secs(secs),
180 _ => {
181 eprintln!(
182 "[sysknife-daemon] WARNING: ignoring invalid \
183 SYSKNIFE_ACTION_TIMEOUT_SECS={raw:?}; using the default"
184 );
185 DEFAULT_ACTION_TIMEOUT
186 }
187 },
188 Err(_) => DEFAULT_ACTION_TIMEOUT,
189 }
190}
191
192async fn kill_and_reap(child: &mut tokio::process::Child, program: &str) -> ExecutorError {
195 let _ = child.start_kill();
196 let _ = child.wait().await;
197 let secs = action_timeout().as_secs();
198 eprintln!("[sysknife-daemon] {program} exceeded the {secs}s action timeout; killed");
199 ExecutorError::Io(io::Error::new(
200 io::ErrorKind::TimedOut,
201 format!("{program} exceeded the {secs}s action timeout and was killed"),
202 ))
203}
204
205async fn execute_command_with_progress(
206 program: &'static str,
207 args: &[String],
208 progress: UnboundedSender<String>,
209) -> Result<ExecutionOutput, ExecutorError> {
210 let mut child = tokio::process::Command::new(program)
211 .args(args)
212 .stdout(Stdio::piped())
213 .stderr(Stdio::piped())
214 .spawn()
215 .map_err(ExecutorError::Io)?;
216
217 let stdout = child.stdout.take().expect("stdout was piped");
218 let stderr = child.stderr.take().expect("stderr was piped");
219 let stderr_task = tokio::spawn(async move {
220 let mut buf = Vec::new();
221 BufReader::new(stderr)
222 .read_to_end(&mut buf)
223 .await
224 .map(|_| buf)
225 });
226
227 let deadline = tokio::time::Instant::now() + action_timeout();
228
229 let mut lines = BufReader::new(stdout).lines();
230 let mut stdout_buf = String::new();
231 loop {
232 let next = match tokio::time::timeout_at(deadline, lines.next_line()).await {
235 Ok(result) => result.map_err(ExecutorError::Io)?,
236 Err(_) => return Err(kill_and_reap(&mut child, program).await),
237 };
238 let Some(line) = next else { break };
239 if !line.is_empty() {
240 let _ = progress.send(line.clone());
241 }
242 stdout_buf.push_str(&line);
243 stdout_buf.push('\n');
244 }
245
246 let exit_status = match tokio::time::timeout_at(deadline, child.wait()).await {
247 Ok(status) => status.map_err(ExecutorError::Io)?,
248 Err(_) => return Err(kill_and_reap(&mut child, program).await),
249 };
250 let stderr_bytes = stderr_task
251 .await
252 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
253 .map_err(ExecutorError::Io)?;
254
255 Ok(ExecutionOutput {
256 stdout: stdout_buf,
257 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
258 exit_code: exit_status.code().unwrap_or(-1),
259 })
260}
261
262pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
267 match action_name {
268 "GetSystemState" => Ok(deployment::get_system_state()),
270 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
271 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
272 "ListDeployments" => Ok(deployment::list_deployments()),
273 "UpdateSystem" => Ok(deployment::update_system()),
274 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
275 "RebootSystem" => Ok(deployment::reboot_system()),
276 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
277 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
278
279 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
281 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
282 "RebaseSystem" => {
283 let target_ref = require_str(params, "target_ref")?;
284 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
285 Ok(deployment::rebase_system(&target_ref))
286 }
287 "SetKernelArguments" => {
288 let add = str_array_or_empty(params, "add")?;
289 let remove = str_array_or_empty(params, "remove")?;
290 for arg in add.iter() {
293 validated_safe_kernel_arg(arg, "add")?;
294 }
295 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
296 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
297 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
298 }
299
300 "ListFlatpakRemotes" => {
306 let username = validated_username(resolve_username(params)?, "username")?;
307 Ok(flatpak::list_flatpak_remotes(&username))
308 }
309 "InstallFlatpak" => {
310 let username = validated_username(resolve_username(params)?, "username")?;
311 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
312 let remote = params
316 .get("remote")
317 .and_then(|v| v.as_str())
318 .unwrap_or("flathub");
319 let remote = validated_safe_arg(remote, "remote")?;
320 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
321 }
322 "RemoveFlatpak" => {
323 let username = validated_username(resolve_username(params)?, "username")?;
324 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
325 Ok(flatpak::remove_flatpak(&username, &app_id))
326 }
327 "SearchFlatpakApps" => {
328 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
329 Ok(flatpak::search_flatpak_apps(&term))
330 }
331 "AddFlatpakRemote" => {
332 let username = validated_username(resolve_username(params)?, "username")?;
333 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
334 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
335 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
336 }
337 "RemoveFlatpakRemote" => {
338 let username = validated_username(resolve_username(params)?, "username")?;
339 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
340 Ok(flatpak::remove_flatpak_remote(&username, &remote))
341 }
342 "GetFlatpakAppInfo" => {
343 let username = validated_username(resolve_username(params)?, "username")?;
344 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
345 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
346 }
347 "ListInstalledFlatpaks" => {
348 let username = validated_username(resolve_username(params)?, "username")?;
349 Ok(flatpak::list_installed_flatpaks(&username))
350 }
351 "UpdateFlatpak" => {
352 let username = validated_username(resolve_username(params)?, "username")?;
353 let app_id = params
356 .get("app_id")
357 .and_then(|v| v.as_str())
358 .filter(|id| !id.is_empty())
359 .map(|id| validated_safe_arg(id, "app_id"))
360 .transpose()?;
361 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
362 }
363
364 "ListContainers" => {
370 let username = validated_username(resolve_username(params)?, "username")?;
371 Ok(containers::list_containers(&username))
372 }
373 "CreateContainer" => {
374 let username = validated_username(resolve_username(params)?, "username")?;
375 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
376 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
377 Ok(containers::create_container(&username, &name, &image))
378 }
379 "StartContainer" => {
380 let username = validated_username(resolve_username(params)?, "username")?;
381 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
382 Ok(containers::start_container(&username, &name))
383 }
384 "StopContainer" => {
385 let username = validated_username(resolve_username(params)?, "username")?;
386 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
387 Ok(containers::stop_container(&username, &name))
388 }
389 "RemoveContainer" => {
390 let username = validated_username(resolve_username(params)?, "username")?;
391 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
392 Ok(containers::remove_container(&username, &name))
393 }
394 "GetContainerInfo" => {
395 let username = validated_username(resolve_username(params)?, "username")?;
396 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
397 Ok(containers::get_container_info(&username, &name))
398 }
399
400 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
402 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
403 "InstallPackages" => {
404 let pkgs = str_array_or_empty(params, "packages")?;
405 let validated: Vec<String> = pkgs
406 .iter()
407 .map(|p| validated_safe_arg(p, "packages"))
408 .collect::<Result<_, _>>()?;
409 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
410 Ok(layering::install_packages(&refs))
411 }
412 "RemovePackages" => {
413 let pkgs = str_array_or_empty(params, "packages")?;
414 let validated: Vec<String> = pkgs
415 .iter()
416 .map(|p| validated_safe_arg(p, "packages"))
417 .collect::<Result<_, _>>()?;
418 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
419 Ok(layering::remove_packages(&refs))
420 }
421 "AddLayeredPackage" => {
422 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
423 Ok(layering::add_layered_package(&package))
424 }
425 "RemoveLayeredPackage" => {
426 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
427 Ok(layering::remove_layered_package(&package))
428 }
429 "ReplaceLayeredPackage" => {
430 let old = validated_safe_arg(require_str(params, "old")?, "old")?;
431 let new = validated_safe_arg(require_str(params, "new")?, "new")?;
432 Ok(layering::replace_layered_package(&old, &new))
433 }
434 "RemoveBasePackage" => {
435 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
436 Ok(layering::remove_base_package(&package))
437 }
438 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
439
440 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
442 "AddPackageRepository" => Ok(package_repos::add_package_repository(
443 validated_repo_id(params)?,
444 validated_no_newline(params, "repo_url")?,
445 )),
446 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
447 validated_repo_id(params)?,
448 )),
449 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
450 validated_repo_id(params)?,
451 )),
452 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
453 validated_repo_id(params)?,
454 )),
455
456 "ListServices" => Ok(services::list_services()),
458 "StartService" => {
459 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
460 Ok(services::start_service(&unit))
461 }
462 "StopService" => {
463 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
464 Ok(services::stop_service(&unit))
465 }
466 "RestartService" => {
467 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
468 Ok(services::restart_service(&unit))
469 }
470 "SetServiceEnabled" => {
471 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
472 Ok(services::set_service_enabled(
473 &unit,
474 require_bool(params, "enabled")?,
475 ))
476 }
477 "MaskService" => {
478 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
479 Ok(services::mask_service(&unit))
480 }
481 "UnmaskService" => {
482 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
483 Ok(services::unmask_service(&unit))
484 }
485 "GetServiceLogs" => {
486 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
487 Ok(services::get_service_logs(&unit))
488 }
489 "GetServiceStatus" => {
490 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
491 Ok(services::get_service_status(&unit))
492 }
493 "ReloadService" => {
494 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
495 Ok(services::reload_service(&unit))
496 }
497 "ListTimers" => Ok(services::list_timers()),
498 "ReloadDaemon" => Ok(services::reload_daemon()),
499 "CreateScheduledJob" => {
500 let name = require_str(params, "name")?;
502 if name.is_empty()
503 || name.len() > 64
504 || !name
505 .chars()
506 .next()
507 .is_some_and(|c| c.is_ascii_alphanumeric())
508 || !name
509 .chars()
510 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
511 {
512 return Err(ExecutorError::InvalidParam("name"));
513 }
514 let command = require_str(params, "command")?;
517 if command.is_empty()
518 || command.len() > MAX_SCHEDULED_COMMAND_LEN
519 || command.chars().any(|c| c.is_control())
520 {
521 return Err(ExecutorError::InvalidParam("command"));
522 }
523 let schedule = require_str(params, "schedule")?;
526 if schedule.is_empty()
527 || schedule.len() > MAX_SCHEDULE_EXPR_LEN
528 || !schedule.chars().all(|c| {
529 c.is_ascii_alphanumeric()
530 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
531 })
532 {
533 return Err(ExecutorError::InvalidParam("schedule"));
534 }
535 Ok(services::create_scheduled_job(name, command, schedule))
536 }
537 "GetServiceResourceLimits" => {
538 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
539 Ok(services::get_service_resource_limits(&unit))
540 }
541 "SetServiceResourceLimits" => {
542 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
543 let mut assignments = Vec::new();
546 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
547 assignments.push(format!("MemoryMax={v}"));
548 }
549 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
550 assignments.push(format!("MemoryHigh={v}"));
551 }
552 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
553 assignments.push(format!("CPUQuota={v}"));
554 }
555 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
556 assignments.push(format!("TasksMax={v}"));
557 }
558 if assignments.is_empty() {
559 return Err(ExecutorError::MissingParam("memory_max"));
560 }
561 Ok(services::set_service_resource_limits(&unit, &assignments))
562 }
563
564 "ListToolboxes" => {
568 let username = validated_username(resolve_username(params)?, "username")?;
569 Ok(toolbox::list_toolboxes(&username))
570 }
571 "CreateToolbox" => {
572 let username = validated_username(resolve_username(params)?, "username")?;
573 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
574 let image = params
575 .get("image")
576 .and_then(|v| v.as_str())
577 .map(|img| validated_safe_arg(img, "image"))
578 .transpose()?;
579 let release = params
580 .get("release")
581 .and_then(|v| v.as_str())
582 .map(|r| validated_safe_arg(r, "release"))
583 .transpose()?;
584 Ok(toolbox::create_toolbox(
585 &username,
586 &name,
587 release.as_deref(),
588 image.as_deref(),
589 ))
590 }
591 "RemoveToolbox" => {
592 let username = validated_username(resolve_username(params)?, "username")?;
593 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
594 Ok(toolbox::remove_toolbox(&username, &name))
595 }
596
597 "GetDateTime" => Ok(identity::get_datetime()),
599 "SetHostname" => {
600 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
601 Ok(identity::set_hostname(&hostname))
602 }
603 "SetTimezone" => {
604 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
605 Ok(identity::set_timezone(&timezone))
606 }
607 "SetLocale" => {
608 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
609 Ok(identity::set_locale(&locale))
610 }
611 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
612
613 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
615
616 "ListProcesses" => Ok(processes::list_processes_spec()),
618 "SignalProcess" => {
619 let pid = params
621 .get("pid")
622 .and_then(|v| {
623 v.as_u64()
624 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
625 })
626 .ok_or(ExecutorError::MissingParam("pid"))?;
627 if pid < 2 || pid > u32::MAX as u64 {
630 return Err(ExecutorError::InvalidParam("pid"));
631 }
632 let signal = validated_kill_signal(
633 params
634 .get("signal")
635 .and_then(|v| v.as_str())
636 .unwrap_or("TERM"),
637 )?;
638 Ok(processes::signal_process(pid as u32, signal))
639 }
640
641 "GetJournalLog" => {
643 let unit = optional_validated(params, "unit", validated_unit_name)?;
644 let priority = optional_validated(params, "priority", validated_journal_priority)?;
645 let since = optional_validated(params, "since", validated_journal_time)?;
646 let until = optional_validated(params, "until", validated_journal_time)?;
647 let grep = optional_validated(params, "grep", validated_journal_grep)?;
648 let lines = params
651 .get("lines")
652 .and_then(|v| v.as_u64())
653 .unwrap_or(100)
654 .clamp(1, MAX_JOURNAL_LINES) as u32;
655 let boot = params
656 .get("boot")
657 .and_then(|v| v.as_bool())
658 .unwrap_or(false);
659 let kernel = params
660 .get("kernel")
661 .and_then(|v| v.as_bool())
662 .unwrap_or(false);
663 Ok(journald::get_journal_log(&journald::JournalQuery {
664 lines,
665 unit: unit.as_deref(),
666 priority: priority.as_deref(),
667 boot,
668 kernel,
669 since: since.as_deref(),
670 until: until.as_deref(),
671 grep: grep.as_deref(),
672 }))
673 }
674 "VacuumJournal" => {
675 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
677 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
678 match (size_mb, retain_days) {
679 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
680 Ok(journald::vacuum_journal_by_size(mb as u32))
681 }
682 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
683 Ok(journald::vacuum_journal_by_time(days as u32))
684 }
685 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
686 _ => Err(ExecutorError::InvalidParam("size_mb")),
688 }
689 }
690
691 "GetLvmReport" => Ok(lvm::get_lvm_report()),
693 "ExtendLogicalVolume" => {
694 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
695 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
696 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
697 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
698 }
699 "CreateLogicalVolume" => {
700 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
701 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
702 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
703 Ok(lvm::create_logical_volume(&vg, &name, &size))
704 }
705 "CreateLvSnapshot" => {
706 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
707 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
708 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
709 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
710 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
711 }
712
713 "GetSysctl" => {
715 let key = optional_validated(params, "key", validated_sysctl_key)?;
717 Ok(sysctl::get_sysctl(key.as_deref()))
718 }
719 "SetSysctl" => {
720 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
721 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
722 Ok(sysctl::set_sysctl(&key, &value))
723 }
724
725 "GetMounts" => Ok(mounts::get_mounts()),
727 "AddMount" => {
728 let device = validated_mount_device(require_str(params, "device")?, "device")?;
729 let mountpoint =
730 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
731 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
732 let options = optional_validated(params, "options", validated_mount_options)?;
733 Ok(mounts::add_mount(
734 &device,
735 &mountpoint,
736 &fstype,
737 options.as_deref(),
738 ))
739 }
740 "RemoveMount" => {
741 let mountpoint =
742 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
743 Ok(mounts::remove_mount(&mountpoint))
744 }
745 "AddSwap" => {
746 let file = validated_swap_path(require_str(params, "file")?, "file")?;
747 let size_mb = require_u32(params, "size_mb")?;
748 if !(1..=MAX_SWAP_SIZE_MB).contains(&size_mb) {
750 return Err(ExecutorError::InvalidParam("size_mb"));
751 }
752 Ok(mounts::add_swap(&file, size_mb))
753 }
754 "RemoveSwap" => {
755 let file = validated_swap_path(require_str(params, "file")?, "file")?;
756 Ok(mounts::remove_swap(&file))
757 }
758
759 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
761 "GrantSudoAccess" => {
762 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
763 let user = validated_username(require_str(params, "user")?, "user")?;
764 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
765 let runas = match params
767 .get("runas")
768 .and_then(|v| v.as_str())
769 .filter(|s| !s.is_empty())
770 {
771 Some("ALL") => Some("ALL".to_string()),
772 Some(u) => Some(validated_username(u, "runas")?),
773 None => None,
774 };
775 let nopasswd = params
776 .get("nopasswd")
777 .and_then(|v| v.as_bool())
778 .unwrap_or(false);
779 if commands == "ALL" && nopasswd {
789 return Err(ExecutorError::InvalidParam("commands"));
790 }
791 Ok(sudoers::grant_sudo_access(
792 &name,
793 &user,
794 &commands,
795 runas.as_deref(),
796 nopasswd,
797 ))
798 }
799 "RevokeSudoAccess" => {
800 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
801 Ok(sudoers::revoke_sudo_access(&name))
802 }
803
804 "GetAptPins" => {
806 let package = optional_validated(params, "package", validated_apt_package)?;
807 Ok(apt_preferences::get_apt_pins(package.as_deref()))
808 }
809 "SetAptPin" => {
810 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
811 let package = validated_apt_package(require_str(params, "package")?, "package")?;
812 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
813 let priority = params
814 .get("priority")
815 .and_then(|v| v.as_i64())
816 .ok_or(ExecutorError::MissingParam("priority"))?;
817 if !(-1..=1000).contains(&priority) {
818 return Err(ExecutorError::InvalidParam("priority"));
819 }
820 Ok(apt_preferences::set_apt_pin(
821 &name, &package, &pin, priority,
822 ))
823 }
824 "RemoveAptPin" => {
825 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
826 Ok(apt_preferences::remove_apt_pin(&name))
827 }
828
829 "GetLogrotateStatus" => {
831 let config = optional_validated(params, "config", validated_log_path)?;
832 Ok(logging::get_logrotate_status(config.as_deref()))
833 }
834 "ConfigureLogRotation" => {
835 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
836 let path = validated_log_path(require_str(params, "path")?, "path")?;
837 let frequency = require_str(params, "frequency")?;
838 if !matches!(frequency, "daily" | "weekly" | "monthly") {
839 return Err(ExecutorError::InvalidParam("frequency"));
840 }
841 let rotate = require_u32(params, "rotate")?;
842 if rotate > 1000 {
843 return Err(ExecutorError::InvalidParam("rotate"));
844 }
845 let compress = params
846 .get("compress")
847 .and_then(|v| v.as_bool())
848 .unwrap_or(false);
849 Ok(logging::configure_log_rotation(
850 &name, &path, frequency, rotate, compress,
851 ))
852 }
853 "RemoveLogRotation" => {
854 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
855 Ok(logging::remove_log_rotation(&name))
856 }
857 "ConfigureRemoteSyslog" => {
858 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
859 let port = require_u32(params, "port")?;
860 if !(1..=crate::actions::validate::MAX_PORT).contains(&port) {
861 return Err(ExecutorError::InvalidParam("port"));
862 }
863 let protocol = require_str(params, "protocol")?;
864 if !matches!(protocol, "tcp" | "udp") {
865 return Err(ExecutorError::InvalidParam("protocol"));
866 }
867 Ok(logging::configure_remote_syslog(
868 &host,
869 port as u16,
870 protocol,
871 ))
872 }
873 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
874
875 "GetPasswordAging" => {
877 let user = validated_username(require_str(params, "user")?, "user")?;
878 Ok(pam::get_password_aging(&user))
879 }
880 "SetPasswordAging" => {
881 let user = validated_username(require_str(params, "user")?, "user")?;
882 let mut flags = Vec::new();
884 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
885 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
886 if n > MAX_PASSWORD_AGE_DAYS {
887 return Err(ExecutorError::InvalidParam(key));
888 }
889 flags.push(opt.to_string());
890 flags.push(n.to_string());
891 }
892 }
893 if flags.is_empty() {
894 return Err(ExecutorError::MissingParam("max_days"));
895 }
896 Ok(pam::set_password_aging(&user, &flags))
897 }
898 "SetPasswordPolicy" => {
899 let mut extra = Vec::new();
902 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
903 if !(1..=MAX_PASSWORD_MINLEN).contains(&n) {
904 return Err(ExecutorError::InvalidParam("minlen"));
905 }
906 extra.push("--minlen".to_string());
907 extra.push(n.to_string());
908 }
909 for (key, flag) in [
910 ("dcredit", "--dcredit"),
911 ("ucredit", "--ucredit"),
912 ("lcredit", "--lcredit"),
913 ("ocredit", "--ocredit"),
914 ] {
915 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
916 if !(-64..=64).contains(&n) {
917 return Err(ExecutorError::InvalidParam(key));
918 }
919 extra.push(flag.to_string());
920 extra.push(n.to_string());
921 }
922 }
923 if extra.is_empty() {
924 return Err(ExecutorError::MissingParam("minlen"));
925 }
926 Ok(pam::set_password_policy(&extra))
927 }
928 "SetAccountLockout" => {
929 let mut extra = Vec::new();
931 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
932 if !(1..=1000).contains(&n) {
933 return Err(ExecutorError::InvalidParam("deny"));
934 }
935 extra.push("--deny".to_string());
936 extra.push(n.to_string());
937 }
938 for (key, flag) in [
939 ("unlock_time", "--unlock-time"),
940 ("fail_interval", "--fail-interval"),
941 ] {
942 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
943 if n > MAX_LOCKOUT_WINDOW_SECS {
944 return Err(ExecutorError::InvalidParam(key));
945 }
946 extra.push(flag.to_string());
947 extra.push(n.to_string());
948 }
949 }
950 if extra.is_empty() {
951 return Err(ExecutorError::MissingParam("deny"));
952 }
953 Ok(pam::set_account_lockout(&extra))
954 }
955
956 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
958
959 "GetFirewallState" => Ok(network::get_firewall_state()),
961 "GetNetworkStatus" => Ok(network::get_network_status()),
962 "GetListeningPorts" => Ok(network::get_listening_ports()),
963 "ConfigureWifi" => {
964 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
965 let password = params
967 .get("password")
968 .and_then(|v| v.as_str())
969 .filter(|p| !p.is_empty())
970 .map(|p| validated_safe_arg(p, "password"))
971 .transpose()?;
972 Ok(network::configure_wifi(&ssid, password.as_deref()))
973 }
974 "SetDnsServers" => {
975 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
976 let servers = str_array_or_empty(params, "servers")?;
977 let validated: Vec<String> = servers
978 .iter()
979 .map(|s| validated_safe_arg(s, "servers"))
980 .collect::<Result<_, _>>()?;
981 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
982 Ok(network::set_dns_servers(&interface, &refs))
983 }
984 "ConfigureFirewall" => {
985 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
986 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
987 Ok(network::configure_firewall(
988 &zone,
989 &service,
990 require_bool(params, "enabled")?,
991 ))
992 }
993
994 "ListUsers" => Ok(users::list_users()),
996 "ListGroups" => Ok(users::list_groups()),
997 "CreateUser" => {
998 let username = validated_username(resolve_username(params)?, "username")?;
999 let shell = params
1000 .get("shell")
1001 .and_then(|v| v.as_str())
1002 .map(|s| validated_safe_arg(s, "shell"))
1003 .transpose()?;
1004 let home = params
1005 .get("home")
1006 .and_then(|v| v.as_str())
1007 .map(|h| validated_safe_arg(h, "home"))
1008 .transpose()?;
1009 Ok(users::create_user(
1010 &username,
1011 shell.as_deref(),
1012 home.as_deref(),
1013 ))
1014 }
1015 "DeleteUser" => {
1016 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1017 Ok(users::delete_user(&username))
1018 }
1019 "AddUserToGroup" => {
1020 let username = validated_username(resolve_username(params)?, "username")?;
1021 let group = validated_group(require_str(params, "group")?, "group")?;
1022 Ok(users::add_user_to_group(&username, &group))
1023 }
1024 "RemoveUserFromGroup" => {
1025 let username = validated_username(resolve_username(params)?, "username")?;
1026 let group = validated_group(require_str(params, "group")?, "group")?;
1027 Ok(users::remove_user_from_group(&username, &group))
1028 }
1029 "CreateGroup" => {
1030 let group = validated_group(require_str(params, "group")?, "group")?;
1031 let system = params
1032 .get("system")
1033 .and_then(|v| v.as_bool())
1034 .unwrap_or(false);
1035 Ok(users::create_group(&group, system))
1036 }
1037 "DeleteGroup" => {
1038 let group = validated_group_not_critical(require_str(params, "group")?, "group")?;
1039 Ok(users::delete_group(&group))
1040 }
1041 "LockUserAccount" => {
1042 let username = validated_username_not_critical(resolve_username(params)?, "username")?;
1043 Ok(users::lock_user_account(&username))
1044 }
1045 "UnlockUserAccount" => {
1046 let username = validated_username(resolve_username(params)?, "username")?;
1047 Ok(users::unlock_user_account(&username))
1048 }
1049
1050 "GetAuthorizedKeys" => {
1052 let username = validated_username(resolve_username(params)?, "username")?;
1053 Ok(ssh::get_authorized_keys(&username))
1054 }
1055 "AddAuthorizedKey" => {
1056 let username = validated_username(resolve_username(params)?, "username")?;
1057 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1058 Ok(ssh::add_authorized_key(&username, &public_key))
1059 }
1060 "RemoveAuthorizedKey" => {
1061 let username = validated_username(resolve_username(params)?, "username")?;
1062 let public_key = validated_public_key(require_str(params, "public_key")?)?;
1063 Ok(ssh::remove_authorized_key(&username, &public_key))
1064 }
1065 "SetSshdOption" => {
1066 let option = require_str(params, "option")?;
1070 let value = require_str(params, "value")?;
1071 let allowed_values: &[&str] = match option {
1072 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
1073 "PasswordAuthentication"
1074 | "PubkeyAuthentication"
1075 | "X11Forwarding"
1076 | "PermitEmptyPasswords" => &["yes", "no"],
1077 _ => return Err(ExecutorError::InvalidParam("option")),
1078 };
1079 if !allowed_values.contains(&value) {
1080 return Err(ExecutorError::InvalidParam("value"));
1081 }
1082 Ok(ssh::set_sshd_option(option, value))
1083 }
1084
1085 "AptUpdate" => Ok(apt::apt_update()),
1087 "AptUpgrade" => Ok(apt::apt_upgrade()),
1088 "AptInstall" => {
1089 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1090 Ok(apt::apt_install(&package))
1091 }
1092 "AptRemove" => {
1093 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1094 Ok(apt::apt_remove(&package))
1095 }
1096 "AptPurge" => {
1097 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1098 Ok(apt::apt_purge(&package))
1099 }
1100 "AptAutoremove" => Ok(apt::apt_autoremove()),
1101 "AptHold" => {
1102 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1103 Ok(apt::apt_hold(&package))
1104 }
1105 "AptUnhold" => {
1106 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1107 Ok(apt::apt_unhold(&package))
1108 }
1109 "AptSearch" => {
1110 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1111 Ok(apt::apt_search(&term))
1112 }
1113 "AptListInstalled" => Ok(apt::apt_list_installed()),
1114 "AptShow" => {
1115 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1116 Ok(apt::apt_show(&package))
1117 }
1118 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1119 "AptHistoryList" => Ok(apt::apt_history_list()),
1120 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1121 params, "enabled",
1122 )?)),
1123
1124 "AddPpa" => {
1126 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1127 Ok(ppa::add_ppa(&name))
1128 }
1129 "RemovePpa" => {
1130 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1131 Ok(ppa::remove_ppa(&name))
1132 }
1133
1134 "SnapInstall" => {
1136 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1137 let channel = params
1138 .get("channel")
1139 .and_then(|v| v.as_str())
1140 .filter(|s| !s.is_empty())
1141 .map(|s| validated_safe_arg(s, "channel"))
1142 .transpose()?;
1143 let auto_update = params
1144 .get("auto_update")
1145 .and_then(|v| v.as_bool())
1146 .unwrap_or(false);
1147 snap::snap_install(&name, channel.as_deref(), auto_update).map_err(|e| match e {
1148 snap::SnapInstallError::InvalidArg { param, .. } => {
1149 ExecutorError::InvalidParam(param)
1150 }
1151 })
1152 }
1153 "SnapRemove" => {
1154 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1155 Ok(snap::snap_remove(&name))
1156 }
1157 "SnapRefresh" => {
1158 let name = params
1159 .get("name")
1160 .and_then(|v| v.as_str())
1161 .filter(|s| !s.is_empty())
1162 .map(|s| validated_safe_arg(s, "name"))
1163 .transpose()?;
1164 Ok(snap::snap_refresh(name.as_deref()))
1165 }
1166 "SnapHold" => {
1167 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1168 Ok(snap::snap_hold(&name))
1169 }
1170 "SnapUnhold" => {
1171 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1172 Ok(snap::snap_unhold(&name))
1173 }
1174 "SnapList" => Ok(snap::snap_list()),
1175 "SnapInfo" => {
1176 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1177 Ok(snap::snap_info(&name))
1178 }
1179 "SnapRevert" => {
1180 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1181 Ok(snap::snap_revert(&name))
1182 }
1183 "SnapClassicInstall" => {
1184 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1185 Ok(snap::snap_classic_install(&name))
1186 }
1187
1188 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1190 "GrubSetKargs" => {
1191 let append: Vec<String> = params
1192 .get("append")
1193 .and_then(|v| v.as_array())
1194 .map(|arr| {
1195 arr.iter()
1196 .filter_map(|v| v.as_str())
1197 .map(String::from)
1198 .collect()
1199 })
1200 .unwrap_or_default();
1201 let delete: Vec<String> = params
1202 .get("delete")
1203 .and_then(|v| v.as_array())
1204 .map(|arr| {
1205 arr.iter()
1206 .filter_map(|v| v.as_str())
1207 .map(String::from)
1208 .collect()
1209 })
1210 .unwrap_or_default();
1211 for a in &append {
1220 validated_safe_arg(a, "append")?;
1221 validated_safe_kernel_arg(a, "append")?;
1222 }
1223 for d in &delete {
1224 validated_safe_arg(d, "delete")?;
1225 }
1226 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1229 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1230 grub::grub_set_kargs(&append_refs, &delete_refs)
1231 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1232 }
1233
1234 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1236
1237 "UfwEnable" => Ok(ufw::ufw_enable()),
1239 "UfwDisable" => Ok(ufw::ufw_disable()),
1240 "UfwAllow" => {
1241 let port_or_service = validated_port_or_service(
1242 require_str(params, "port_or_service")?,
1243 "port_or_service",
1244 )?;
1245 Ok(ufw::ufw_allow(&port_or_service))
1246 }
1247 "UfwDeny" => {
1248 let port_or_service = validated_port_or_service(
1249 require_str(params, "port_or_service")?,
1250 "port_or_service",
1251 )?;
1252 Ok(ufw::ufw_deny(&port_or_service))
1253 }
1254 "UfwReset" => Ok(ufw::ufw_reset()),
1255 "UfwStatus" => Ok(ufw::ufw_status()),
1256
1257 "DistroboxList" => Ok(distrobox::distrobox_list()),
1259 "DistroboxCreate" => {
1260 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1261 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1262 Ok(distrobox::distrobox_create(&name, &image))
1263 }
1264 "DistroboxRemove" => {
1265 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1266 Ok(distrobox::distrobox_remove(&name))
1267 }
1268
1269 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1271 "NetplanApply" => Ok(netplan::netplan_apply()),
1272 "NetplanSet" => {
1273 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1274 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1275 Ok(netplan::netplan_set(&key, &value))
1276 }
1277 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1278
1279 "UfwDeleteRule" => {
1281 let rule_number = require_positive_u32(params, "rule_number")?;
1282 ufw::ufw_delete_rule(rule_number)
1283 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1284 }
1285 "UfwLimit" => {
1286 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1287 Ok(ufw::ufw_limit(&target))
1288 }
1289
1290 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1292 "ProAttach" => {
1293 let token = require_str(params, "token")?;
1295 let token = validated_safe_arg(token, "token")?;
1297 Ok(ubuntu_pro::pro_attach(&token))
1298 }
1299 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1300 "EnableProService" => {
1301 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1302 Ok(ubuntu_pro::enable_pro_service(&service))
1303 }
1304 "DisableProService" => {
1305 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1306 Ok(ubuntu_pro::disable_pro_service(&service))
1307 }
1308
1309 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1311
1312 "MultipassList" => Ok(multipass::multipass_list()),
1314
1315 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1317
1318 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1320 "ResolvectlSetDns" => {
1321 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1322 let raw_servers: Vec<String> = params
1323 .get("servers")
1324 .and_then(|v| v.as_array())
1325 .map(|arr| {
1326 arr.iter()
1327 .filter_map(|v| v.as_str())
1328 .map(String::from)
1329 .collect()
1330 })
1331 .unwrap_or_default();
1332 if raw_servers.is_empty() {
1333 return Err(ExecutorError::MissingParam("servers"));
1334 }
1335 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1339 for s in &raw_servers {
1340 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1341 param: "servers",
1342 value: s.clone(),
1343 })?;
1344 parsed_servers.push(addr);
1345 }
1346 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1347 }
1348
1349 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1351 "AppArmorEnforce" => {
1352 let profile_path =
1353 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1354 Ok(apparmor::apparmor_enforce(&profile_path))
1355 }
1356 "AppArmorComplain" => {
1357 let profile_path =
1358 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1359 Ok(apparmor::apparmor_complain(&profile_path))
1360 }
1361
1362 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1364
1365 "UbuntuInstallFlatpak" => {
1367 let username = validated_username(resolve_username(params)?, "username")?;
1368 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1369 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1370 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1371 }
1372 "UbuntuRemoveFlatpak" => {
1373 let username = validated_username(resolve_username(params)?, "username")?;
1374 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1375 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1376 }
1377 "UbuntuUpdateFlatpak" => {
1378 let username = validated_username(resolve_username(params)?, "username")?;
1379 let app_id = params
1380 .get("app_id")
1381 .and_then(|v| v.as_str())
1382 .filter(|s| !s.is_empty())
1383 .map(|s| validated_safe_arg(s, "app_id"))
1384 .transpose()?;
1385 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1386 }
1387 "UbuntuListFlatpaks" => {
1388 let username = validated_username(resolve_username(params)?, "username")?;
1389 Ok(flatpak::ubuntu_list_flatpaks(&username))
1390 }
1391
1392 "Fail2banStatus" => {
1394 let jail = params
1395 .get("jail")
1396 .and_then(|v| v.as_str())
1397 .filter(|s| !s.is_empty())
1398 .map(|s| validated_safe_arg(s, "jail"))
1399 .transpose()?;
1400 fail2ban::fail2ban_status(jail.as_deref()).map_err(|e| match e {
1401 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("jail"),
1402 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1403 })
1404 }
1405 "Fail2banBanIp" => {
1406 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1407 let ip = require_str(params, "ip")?;
1408 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1409 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1410 param: "ip",
1411 value: v,
1412 },
1413 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1414 })
1415 }
1416 "Fail2banUnbanIp" => {
1417 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1418 let ip = require_str(params, "ip")?;
1419 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1420 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1421 param: "ip",
1422 value: v,
1423 },
1424 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1425 })
1426 }
1427 "ConfigureFail2banJail" => {
1428 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1429 let mut extra = Vec::new();
1430 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1431 extra.push("--enabled".to_string());
1432 extra.push(if n { "true" } else { "false" }.to_string());
1433 }
1434 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1435 if !(1..=100).contains(&n) {
1436 return Err(ExecutorError::InvalidParam("maxretry"));
1437 }
1438 extra.push("--maxretry".to_string());
1439 extra.push(n.to_string());
1440 }
1441 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1442 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1443 if n > MAX_FAIL2BAN_WINDOW_SECS {
1444 return Err(ExecutorError::InvalidParam(key));
1445 }
1446 extra.push(flag.to_string());
1447 extra.push(n.to_string());
1448 }
1449 }
1450 if extra.is_empty() {
1451 return Err(ExecutorError::MissingParam("maxretry"));
1452 }
1453 fail2ban::configure_fail2ban_jail(&name, &extra).map_err(|e| match e {
1454 fail2ban::Fail2banError::InvalidIpAddress(_) => ExecutorError::InvalidParam("name"),
1455 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("name"),
1456 })
1457 }
1458
1459 "GetAuditRules" => Ok(auditd::get_audit_rules()),
1461 "AddAuditRule" => {
1462 let path = validated_audit_path(require_str(params, "path")?, "path")?;
1463 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1464 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1465 Ok(auditd::add_audit_rule(&path, &perms, &key))
1466 }
1467 "RemoveAuditRule" => {
1468 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1469 Ok(auditd::remove_audit_rule(&key))
1470 }
1471
1472 "GetCertificates" => Ok(certbot::get_certificates()),
1474 "ObtainCertificate" => {
1475 let mut domains = Vec::new();
1477 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1478 for d in arr {
1479 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1480 domains.push(validated_domain(s, "domains")?);
1481 }
1482 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1483 domains.push(validated_domain(d, "domain")?);
1484 }
1485 if domains.is_empty() {
1486 return Err(ExecutorError::MissingParam("domains"));
1487 }
1488 let email = validated_email(require_str(params, "email")?, "email")?;
1489 let challenge = params
1490 .get("challenge")
1491 .and_then(|v| v.as_str())
1492 .unwrap_or("standalone");
1493 if !matches!(challenge, "standalone" | "nginx" | "apache") {
1494 return Err(ExecutorError::InvalidParam("challenge"));
1495 }
1496 Ok(certbot::obtain_certificate(&domains, &email, challenge))
1497 }
1498 "RenewCertificates" => Ok(certbot::renew_certificates()),
1499
1500 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1501 }
1502}
1503
1504pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1510 match &spec.mechanism {
1511 ActionMechanism::Command { program, args } => {
1512 let mut child = tokio::process::Command::new(program)
1516 .args(args)
1517 .stdin(Stdio::null())
1518 .stdout(Stdio::piped())
1519 .stderr(Stdio::piped())
1520 .spawn()?;
1521
1522 let stdout_h = child.stdout.take().expect("stdout was piped");
1527 let stderr_h = child.stderr.take().expect("stderr was piped");
1528 let stdout_task = tokio::spawn(async move {
1529 let mut buf = Vec::new();
1530 BufReader::new(stdout_h)
1531 .read_to_end(&mut buf)
1532 .await
1533 .map(|_| buf)
1534 });
1535 let stderr_task = tokio::spawn(async move {
1536 let mut buf = Vec::new();
1537 BufReader::new(stderr_h)
1538 .read_to_end(&mut buf)
1539 .await
1540 .map(|_| buf)
1541 });
1542
1543 let status = match tokio::time::timeout(action_timeout(), child.wait()).await {
1544 Ok(status) => status?,
1545 Err(_) => return Err(kill_and_reap(&mut child, program).await),
1546 };
1547 let join =
1548 |e| ExecutorError::Io(io::Error::other(format!("reader task panicked: {e}")));
1549 let stdout = stdout_task.await.map_err(join)??;
1550 let stderr = stderr_task.await.map_err(join)??;
1551
1552 Ok(ExecutionOutput {
1553 stdout: String::from_utf8_lossy(&stdout).into_owned(),
1554 stderr: String::from_utf8_lossy(&stderr).into_owned(),
1555 exit_code: status.code().unwrap_or(-1),
1556 })
1557 }
1558 ActionMechanism::FileScan { path } => {
1559 let mut entries = tokio::fs::read_dir(path).await?;
1560 let mut names = Vec::new();
1561 while let Some(entry) = entries.next_entry().await? {
1562 names.push(entry.file_name().to_string_lossy().into_owned());
1563 }
1564 names.sort();
1565 Ok(ExecutionOutput {
1566 stdout: names.join("\n"),
1567 stderr: String::new(),
1568 exit_code: 0,
1569 })
1570 }
1571 ActionMechanism::FileWrite { path, content } => {
1572 if let Some(parent) = std::path::Path::new(path).parent() {
1573 tokio::fs::create_dir_all(parent).await?;
1574 }
1575 tokio::fs::write(path, content).await?;
1576 Ok(ExecutionOutput {
1577 stdout: String::new(),
1578 stderr: String::new(),
1579 exit_code: 0,
1580 })
1581 }
1582 ActionMechanism::FilePatch {
1583 path,
1584 search,
1585 replace,
1586 } => {
1587 let content = tokio::fs::read_to_string(path).await?;
1588 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1589 if patched == content && !search.is_empty() {
1590 return Ok(ExecutionOutput {
1591 stdout: String::new(),
1592 stderr: format!("search string not found in file: {}", path),
1593 exit_code: 1,
1594 });
1595 }
1596 tokio::fs::write(path, patched).await?;
1597 Ok(ExecutionOutput {
1598 stdout: String::new(),
1599 stderr: String::new(),
1600 exit_code: 0,
1601 })
1602 }
1603 ActionMechanism::FileDelete { path } => {
1604 tokio::fs::remove_file(path).await?;
1605 Ok(ExecutionOutput {
1606 stdout: String::new(),
1607 stderr: String::new(),
1608 exit_code: 0,
1609 })
1610 }
1611 }
1612}
1613
1614fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
1615 match params.get(key) {
1616 None => Err(ExecutorError::MissingParam(key)),
1617 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
1618 }
1619}
1620
1621fn optional_validated<F>(
1625 params: &Value,
1626 key: &'static str,
1627 validator: F,
1628) -> Result<Option<String>, ExecutorError>
1629where
1630 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
1631{
1632 match params
1633 .get(key)
1634 .and_then(|v| v.as_str())
1635 .filter(|s| !s.is_empty())
1636 {
1637 Some(s) => Ok(Some(validator(s, key)?)),
1638 None => Ok(None),
1639 }
1640}
1641
1642fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
1652 params
1653 .get("username")
1654 .or_else(|| params.get("user"))
1655 .ok_or(ExecutorError::MissingParam("username"))
1656 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
1657}
1658
1659fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
1663 let id = require_str(params, "repo_id")?;
1664 let valid = !id.is_empty()
1665 && id
1666 .chars()
1667 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1668 if valid {
1669 Ok(id)
1670 } else {
1671 Err(ExecutorError::InvalidParam("repo_id"))
1672 }
1673}
1674
1675fn validated_no_newline<'a>(
1678 params: &'a Value,
1679 key: &'static str,
1680) -> Result<&'a str, ExecutorError> {
1681 let val = require_str(params, key)?;
1682 if val.contains('\n') || val.contains('\r') {
1683 Err(ExecutorError::InvalidParam(key))
1684 } else {
1685 Ok(val)
1686 }
1687}
1688
1689fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
1693 const MAX_LEN: usize = 8192;
1694 const ALLOWED_PREFIXES: &[&str] = &[
1695 "ssh-rsa",
1696 "ssh-ed25519",
1697 "ssh-ed25519-sk",
1698 "ecdsa-sha2-nistp256",
1699 "ecdsa-sha2-nistp384",
1700 "ecdsa-sha2-nistp521",
1701 "sk-ssh-ed25519",
1702 "sk-ecdsa-sha2-nistp256",
1703 ];
1704
1705 if s.is_empty() || s.len() > MAX_LEN {
1706 return Err(ExecutorError::InvalidParam("public_key"));
1707 }
1708 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
1709 return Err(ExecutorError::InvalidParam("public_key"));
1710 }
1711 if s.chars().any(|c| {
1726 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
1727 || !c.is_ascii()
1728 || c.is_ascii_control()
1729 }) {
1730 return Err(ExecutorError::InvalidParam("public_key"));
1731 }
1732 Ok(s.to_string())
1733}
1734
1735fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
1736 match params.get(key) {
1737 None => Err(ExecutorError::MissingParam(key)),
1738 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
1739 }
1740}
1741
1742fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1743 match params.get(key) {
1744 None => Err(ExecutorError::MissingParam(key)),
1745 Some(v) => {
1746 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
1747 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
1748 }
1749 }
1750}
1751
1752fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1756 let n = require_u32(params, key)?;
1757 if n == 0 {
1758 return Err(ExecutorError::InvalidParam(key));
1759 }
1760 Ok(n)
1761}
1762
1763fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
1767 match params.get(key) {
1768 None | Some(Value::Null) => Ok(vec![]),
1769 Some(Value::Array(arr)) => arr
1770 .iter()
1771 .map(|v| {
1772 v.as_str()
1773 .map(String::from)
1774 .ok_or(ExecutorError::InvalidParam(key))
1775 })
1776 .collect(),
1777 _ => Err(ExecutorError::InvalidParam(key)),
1778 }
1779}
1780
1781fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
1814 const BLOCKED_PREFIXES: &[&str] = &[
1815 "init=",
1816 "selinux=0",
1817 "enforcing=0",
1818 "security=",
1819 "module_blacklist=",
1820 "apparmor=0",
1821 "mitigations=off",
1822 "lockdown=",
1823 "pti=off",
1824 ];
1825 const BLOCKED_EXACT: &[&str] = &["single", "s", "1", "nosmap", "nosmep"];
1826 const BLOCKED_UNIT_PREFIXES: &[&str] = &["emergency", "rescue", "single"];
1827
1828 let lower = arg.to_lowercase();
1829 let base = lower.split('=').next().unwrap_or(&lower);
1831
1832 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
1833 return Err(ExecutorError::InvalidParam(param));
1834 }
1835 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
1836 return Err(ExecutorError::InvalidParam(param));
1837 }
1838 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
1840 if BLOCKED_UNIT_PREFIXES
1841 .iter()
1842 .any(|u| unit_val.starts_with(u))
1843 {
1844 return Err(ExecutorError::InvalidParam(param));
1845 }
1846 }
1847 if BLOCKED_EXACT.contains(&base) {
1849 return Err(ExecutorError::InvalidParam(param));
1850 }
1851 Ok(())
1852}
1853
1854fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
1862 let normalized = s.trim().to_ascii_uppercase();
1863 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
1864 "TERM" => Ok("TERM"),
1865 "KILL" => Ok("KILL"),
1866 "HUP" => Ok("HUP"),
1867 "INT" => Ok("INT"),
1868 _ => Err(ExecutorError::InvalidParam("signal")),
1869 }
1870}
1871
1872pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
1882 match action_name {
1883 "UpdateSystem"
1884 | "InstallPackages"
1885 | "RemovePackages"
1886 | "RebaseSystem"
1887 | "SetKernelArguments"
1888 | "AddLayeredPackage"
1889 | "RemoveLayeredPackage"
1890 | "ReplaceLayeredPackage"
1891 | "ResetLayeredPackageOverride"
1892 | "RemoveBasePackage" => Some(ActionSpec {
1893 action_name: "RollbackDeployment",
1894 mechanism: ActionMechanism::Command {
1895 program: "rpm-ostree",
1896 args: vec!["rollback".to_string()],
1897 },
1898 risk_level: sysknife_types::RiskLevel::High,
1899 reboot_required: true,
1900 rollback_available: false,
1901 }),
1902 _ => None,
1903 }
1904}
1905
1906#[cfg(test)]
1907mod tests {
1908 use super::*;
1909 use serde_json::json;
1910 use sysknife_types::RiskLevel;
1911 use tempfile::tempdir;
1912
1913 #[test]
1916 fn build_spec_no_params_for_get_system_state() {
1917 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
1918 assert_eq!(spec.action_name, "GetSystemState");
1919 assert_eq!(spec.risk_level, RiskLevel::Low);
1920 }
1921
1922 #[test]
1923 fn build_spec_get_datetime_is_low_risk() {
1924 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
1925 assert_eq!(spec.action_name, "GetDateTime");
1926 assert_eq!(spec.risk_level, RiskLevel::Low);
1927 assert!(!spec.reboot_required);
1928 }
1929
1930 #[test]
1931 fn build_spec_unknown_action_returns_error() {
1932 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
1933 assert!(
1934 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
1935 "expected UnknownAction, got: {err}"
1936 );
1937 }
1938
1939 #[test]
1940 fn build_spec_missing_param_for_install_flatpak() {
1941 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
1943 assert!(
1944 matches!(err, ExecutorError::MissingParam("username")),
1945 "expected MissingParam(username), got: {err}"
1946 );
1947 }
1948
1949 #[test]
1953 fn build_spec_flatpak_accepts_user_alias() {
1954 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
1955 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
1956 }
1957
1958 #[test]
1960 fn build_spec_resolve_username_prefers_explicit_username() {
1961 let spec = build_action_spec(
1962 "ListInstalledFlatpaks",
1963 &json!({ "username": "alice", "user": "bob" }),
1964 )
1965 .unwrap();
1966 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
1968 }
1969
1970 #[test]
1973 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
1974 let spec = build_action_spec(
1975 "InstallFlatpak",
1976 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
1977 )
1978 .unwrap();
1979 assert_eq!(
1980 spec.mechanism,
1981 ActionMechanism::Command {
1982 program: "sudo",
1983 args: vec![
1984 "runuser".to_string(),
1985 "-u".to_string(),
1986 "alice".to_string(),
1987 "--".to_string(),
1988 "flatpak".to_string(),
1989 "install".to_string(),
1990 "--user".to_string(),
1991 "-y".to_string(),
1992 "flathub".to_string(),
1993 "org.mozilla.firefox".to_string(),
1994 ],
1995 }
1996 );
1997 }
1998
1999 #[test]
2000 fn build_spec_install_flatpak_injects_app_and_remote() {
2001 let spec = build_action_spec(
2002 "InstallFlatpak",
2003 &json!({
2004 "username": "alice",
2005 "app_id": "org.mozilla.firefox",
2006 "remote": "flathub"
2007 }),
2008 )
2009 .unwrap();
2010 assert_eq!(spec.action_name, "InstallFlatpak");
2011 assert_eq!(
2012 spec.mechanism,
2013 ActionMechanism::Command {
2014 program: "sudo",
2015 args: vec![
2016 "runuser".to_string(),
2017 "-u".to_string(),
2018 "alice".to_string(),
2019 "--".to_string(),
2020 "flatpak".to_string(),
2021 "install".to_string(),
2022 "--user".to_string(),
2023 "-y".to_string(),
2024 "flathub".to_string(),
2025 "org.mozilla.firefox".to_string(),
2026 ],
2027 }
2028 );
2029 }
2030
2031 #[test]
2032 fn build_spec_pin_deployment_injects_index() {
2033 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
2034 assert_eq!(
2035 spec.mechanism,
2036 ActionMechanism::Command {
2037 program: "sudo",
2038 args: vec![
2039 "ostree".to_string(),
2040 "admin".to_string(),
2041 "pin".to_string(),
2042 "1".to_string()
2043 ],
2044 }
2045 );
2046 }
2047
2048 #[test]
2049 fn build_spec_unpin_deployment_includes_unpin_flag() {
2050 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
2051 assert_eq!(
2052 spec.mechanism,
2053 ActionMechanism::Command {
2054 program: "sudo",
2055 args: vec![
2056 "ostree".to_string(),
2057 "admin".to_string(),
2058 "pin".to_string(),
2059 "--unpin".to_string(),
2060 "2".to_string(),
2061 ],
2062 }
2063 );
2064 }
2065
2066 #[test]
2067 fn require_u32_rejects_overflow() {
2068 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
2069 assert!(
2070 matches!(err, ExecutorError::InvalidParam("index")),
2071 "expected InvalidParam(index), got: {err}"
2072 );
2073 }
2074
2075 #[test]
2076 fn build_spec_rebase_system_injects_target_ref() {
2077 let spec = build_action_spec(
2078 "RebaseSystem",
2079 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
2080 )
2081 .unwrap();
2082 assert_eq!(
2083 spec.mechanism,
2084 ActionMechanism::Command {
2085 program: "sudo",
2086 args: vec![
2087 "rpm-ostree".to_string(),
2088 "rebase".to_string(),
2089 "fedora/41/x86_64/silverblue".to_string(),
2090 ],
2091 }
2092 );
2093 }
2094
2095 #[test]
2096 fn build_spec_set_kernel_arguments_appends_and_deletes() {
2097 let spec = build_action_spec(
2098 "SetKernelArguments",
2099 &json!({ "add": ["nomodeset"], "remove": ["quiet"] }),
2100 )
2101 .unwrap();
2102 assert_eq!(
2103 spec.mechanism,
2104 ActionMechanism::Command {
2105 program: "sudo",
2106 args: vec![
2107 "rpm-ostree".to_string(),
2108 "kargs".to_string(),
2109 "--append=nomodeset".to_string(),
2110 "--delete=quiet".to_string(),
2111 ],
2112 }
2113 );
2114 }
2115
2116 #[test]
2117 fn build_spec_set_kernel_arguments_with_empty_arrays() {
2118 let spec =
2119 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
2120 assert_eq!(
2121 spec.mechanism,
2122 ActionMechanism::Command {
2123 program: "sudo",
2124 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2125 }
2126 );
2127 }
2128
2129 #[test]
2130 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
2131 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
2132 assert_eq!(
2133 spec.mechanism,
2134 ActionMechanism::Command {
2135 program: "sudo",
2136 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
2137 }
2138 );
2139 }
2140
2141 #[test]
2144 fn delete_user_rejects_critical_account() {
2145 let err = build_action_spec("DeleteUser", &json!({ "username": "root" })).unwrap_err();
2146 assert!(
2147 matches!(err, ExecutorError::InvalidParam("username")),
2148 "expected InvalidParam(username), got: {err}"
2149 );
2150 }
2151
2152 #[test]
2153 fn delete_group_rejects_critical_group() {
2154 let err = build_action_spec("DeleteGroup", &json!({ "group": "sudo" })).unwrap_err();
2155 assert!(
2156 matches!(err, ExecutorError::InvalidParam("group")),
2157 "expected InvalidParam(group), got: {err}"
2158 );
2159 }
2160
2161 #[test]
2162 fn lock_user_account_rejects_critical_account() {
2163 let err = build_action_spec("LockUserAccount", &json!({ "username": "root" })).unwrap_err();
2164 assert!(
2165 matches!(err, ExecutorError::InvalidParam("username")),
2166 "expected InvalidParam(username), got: {err}"
2167 );
2168 }
2169
2170 #[test]
2171 fn delete_user_lock_user_account_and_delete_group_allow_normal_names() {
2172 assert!(build_action_spec("DeleteUser", &json!({ "username": "alice" })).is_ok());
2173 assert!(build_action_spec("LockUserAccount", &json!({ "username": "alice" })).is_ok());
2174 assert!(build_action_spec("DeleteGroup", &json!({ "group": "developers" })).is_ok());
2175 }
2176
2177 #[test]
2180 fn build_spec_add_package_repository_rejects_path_traversal() {
2181 let err = build_action_spec(
2182 "AddPackageRepository",
2183 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
2184 )
2185 .unwrap_err();
2186 assert!(
2187 matches!(err, ExecutorError::InvalidParam("repo_id")),
2188 "expected InvalidParam(repo_id), got: {err}"
2189 );
2190 }
2191
2192 #[test]
2193 fn build_spec_add_package_repository_rejects_newline_in_url() {
2194 let err = build_action_spec(
2195 "AddPackageRepository",
2196 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2197 )
2198 .unwrap_err();
2199 assert!(
2200 matches!(err, ExecutorError::InvalidParam("repo_url")),
2201 "expected InvalidParam(repo_url), got: {err}"
2202 );
2203 }
2204
2205 #[test]
2206 fn build_spec_add_package_repository_accepts_valid_repo_id() {
2207 let spec = build_action_spec(
2208 "AddPackageRepository",
2209 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2210 )
2211 .unwrap();
2212 assert_eq!(spec.action_name, "AddPackageRepository");
2213 }
2214
2215 #[test]
2216 fn build_spec_remove_package_repository_rejects_path_traversal() {
2217 let err = build_action_spec(
2218 "RemovePackageRepository",
2219 &json!({ "repo_id": "../../etc/passwd" }),
2220 )
2221 .unwrap_err();
2222 assert!(
2223 matches!(err, ExecutorError::InvalidParam("repo_id")),
2224 "expected InvalidParam(repo_id), got: {err}"
2225 );
2226 }
2227
2228 #[tokio::test]
2229 async fn execute_spec_command_captures_stdout() {
2230 let spec = ActionSpec {
2231 action_name: "GetSystemState",
2232 mechanism: ActionMechanism::Command {
2233 program: "echo",
2234 args: vec!["hello".to_string()],
2235 },
2236 risk_level: RiskLevel::Low,
2237 reboot_required: false,
2238 rollback_available: false,
2239 };
2240 let out = execute_spec(&spec).await.unwrap();
2241 assert_eq!(out.stdout.trim(), "hello");
2242 assert_eq!(out.exit_code, 0);
2243 }
2244
2245 #[tokio::test]
2251 async fn real_executor_streams_each_stdout_line() {
2252 let spec = ActionSpec {
2253 action_name: "GetSystemState",
2254 mechanism: ActionMechanism::Command {
2255 program: "printf",
2256 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2257 },
2258 risk_level: RiskLevel::Low,
2259 reboot_required: false,
2260 rollback_available: false,
2261 };
2262 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2263 let out = RealActionExecutor
2264 .execute_with_progress(&spec, tx)
2265 .await
2266 .unwrap();
2267
2268 let mut lines = Vec::new();
2269 while let Ok(line) = rx.try_recv() {
2270 lines.push(line);
2271 }
2272 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2273 assert_eq!(out.exit_code, 0);
2274 assert!(out.stdout.contains("line-two"));
2275 }
2276
2277 #[tokio::test]
2278 async fn execute_spec_file_write_creates_file() {
2279 let dir = tempdir().unwrap();
2280 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2281 let spec = ActionSpec {
2282 action_name: "AddPackageRepository",
2283 mechanism: ActionMechanism::FileWrite {
2284 path: path.clone(),
2285 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2286 },
2287 risk_level: RiskLevel::Medium,
2288 reboot_required: false,
2289 rollback_available: true,
2290 };
2291 let out = execute_spec(&spec).await.unwrap();
2292 assert_eq!(out.exit_code, 0);
2293 assert_eq!(
2294 std::fs::read_to_string(&path).unwrap(),
2295 "[repo]\nbaseurl=https://example.test\n"
2296 );
2297 }
2298
2299 #[tokio::test]
2300 async fn execute_spec_file_patch_replaces_first_occurrence() {
2301 let dir = tempdir().unwrap();
2302 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2303 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2304 let spec = ActionSpec {
2305 action_name: "EnablePackageRepository",
2306 mechanism: ActionMechanism::FilePatch {
2307 path: path.clone(),
2308 search: "enabled=0".to_string(),
2309 replace: "enabled=1".to_string(),
2310 },
2311 risk_level: RiskLevel::Medium,
2312 reboot_required: false,
2313 rollback_available: true,
2314 };
2315 execute_spec(&spec).await.unwrap();
2316 assert_eq!(
2317 std::fs::read_to_string(&path).unwrap(),
2318 "[myrepo]\nenabled=1\n"
2319 );
2320 }
2321
2322 #[tokio::test]
2323 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2324 let dir = tempdir().unwrap();
2325 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2326 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2327 let spec = ActionSpec {
2328 action_name: "EnablePackageRepository",
2329 mechanism: ActionMechanism::FilePatch {
2330 path: path.clone(),
2331 search: "enabled=0".to_string(),
2332 replace: "enabled=1".to_string(),
2333 },
2334 risk_level: RiskLevel::Medium,
2335 reboot_required: false,
2336 rollback_available: true,
2337 };
2338 let out = execute_spec(&spec).await.unwrap();
2339 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2340 assert!(
2341 out.stderr.contains("search string not found in file"),
2342 "stderr should explain the failure: {}",
2343 out.stderr
2344 );
2345 assert_eq!(
2347 std::fs::read_to_string(&path).unwrap(),
2348 "[myrepo]\nenabled=1\n"
2349 );
2350 }
2351
2352 #[tokio::test]
2353 async fn execute_spec_file_patch_allows_empty_search_string() {
2354 let dir = tempdir().unwrap();
2357 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
2358 std::fs::write(&path, "hello").unwrap();
2359 let spec = ActionSpec {
2360 action_name: "Test",
2361 mechanism: ActionMechanism::FilePatch {
2362 path: path.clone(),
2363 search: String::new(),
2364 replace: "prefix-".to_string(),
2365 },
2366 risk_level: RiskLevel::Low,
2367 reboot_required: false,
2368 rollback_available: false,
2369 };
2370 let out = execute_spec(&spec).await.unwrap();
2371 assert_eq!(out.exit_code, 0);
2372 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
2373 }
2374
2375 #[tokio::test]
2376 async fn execute_spec_file_delete_removes_file() {
2377 let dir = tempdir().unwrap();
2378 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2379 std::fs::write(&path, "[myrepo]\n").unwrap();
2380 let spec = ActionSpec {
2381 action_name: "RemovePackageRepository",
2382 mechanism: ActionMechanism::FileDelete { path: path.clone() },
2383 risk_level: RiskLevel::Medium,
2384 reboot_required: false,
2385 rollback_available: true,
2386 };
2387 execute_spec(&spec).await.unwrap();
2388 assert!(!std::path::Path::new(&path).exists());
2389 }
2390
2391 #[tokio::test]
2392 async fn execute_spec_file_scan_lists_directory_entries() {
2393 let dir = tempdir().unwrap();
2394 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
2395 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
2396 let spec = ActionSpec {
2397 action_name: "ListPackageRepositories",
2398 mechanism: ActionMechanism::FileScan {
2399 path: dir.path().to_string_lossy().into_owned(),
2400 },
2401 risk_level: RiskLevel::Low,
2402 reboot_required: false,
2403 rollback_available: false,
2404 };
2405 let out = execute_spec(&spec).await.unwrap();
2406 assert!(
2407 out.stdout.contains("a.repo"),
2408 "expected a.repo in: {}",
2409 out.stdout
2410 );
2411 assert!(
2412 out.stdout.contains("b.repo"),
2413 "expected b.repo in: {}",
2414 out.stdout
2415 );
2416 assert_eq!(out.exit_code, 0);
2417 }
2418
2419 #[test]
2422 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
2423 let spec = rollback_spec_for("UpdateSystem").unwrap();
2424 assert_eq!(spec.action_name, "RollbackDeployment");
2425 assert!(
2426 matches!(
2427 &spec.mechanism,
2428 ActionMechanism::Command { program: "rpm-ostree", args }
2429 if args == &["rollback".to_string()]
2430 ),
2431 "expected rpm-ostree rollback, got: {:?}",
2432 spec.mechanism
2433 );
2434 assert!(!spec.rollback_available, "rollback spec must not recurse");
2435 }
2436
2437 #[test]
2438 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
2439 assert!(rollback_spec_for("InstallPackages").is_some());
2440 }
2441
2442 #[test]
2443 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
2444 assert!(rollback_spec_for("RemovePackages").is_some());
2445 }
2446
2447 #[test]
2448 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
2449 assert!(rollback_spec_for("RebaseSystem").is_some());
2450 }
2451
2452 #[test]
2453 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
2454 assert!(rollback_spec_for("SetKernelArguments").is_some());
2455 }
2456
2457 #[test]
2458 fn rollback_spec_for_read_only_action_returns_none() {
2459 assert!(rollback_spec_for("GetSystemState").is_none());
2460 assert!(rollback_spec_for("ListUsers").is_none());
2461 assert!(rollback_spec_for("GetFirewallState").is_none());
2462 }
2463
2464 #[test]
2465 fn rollback_spec_for_non_rollbackable_actions_return_none() {
2466 assert!(rollback_spec_for("AddUserToGroup").is_none());
2467 assert!(rollback_spec_for("DeleteUser").is_none());
2468 assert!(rollback_spec_for("CleanupDeployments").is_none());
2469 assert!(rollback_spec_for("RollbackDeployment").is_none());
2471 }
2472
2473 #[test]
2476 fn public_key_accepts_valid_ed25519() {
2477 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
2478 assert!(validated_public_key(key).is_ok());
2479 }
2480
2481 #[test]
2482 fn public_key_accepts_valid_rsa() {
2483 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
2484 assert!(validated_public_key(key).is_ok());
2485 }
2486
2487 #[test]
2488 fn public_key_rejects_empty() {
2489 assert!(matches!(
2490 validated_public_key(""),
2491 Err(ExecutorError::InvalidParam("public_key"))
2492 ));
2493 }
2494
2495 #[test]
2496 fn public_key_rejects_unknown_prefix() {
2497 assert!(matches!(
2498 validated_public_key("sk-rsa AAAA... user@host"),
2499 Err(ExecutorError::InvalidParam("public_key"))
2500 ));
2501 assert!(matches!(
2502 validated_public_key("AAAAB3Nz... user@host"),
2503 Err(ExecutorError::InvalidParam("public_key"))
2504 ));
2505 }
2506
2507 #[test]
2508 fn public_key_rejects_single_quote() {
2509 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
2510 assert!(matches!(
2511 validated_public_key(key),
2512 Err(ExecutorError::InvalidParam("public_key"))
2513 ));
2514 }
2515
2516 #[test]
2517 fn authorized_keys_actions_reject_a_traversal_username() {
2518 for action in [
2525 "GetAuthorizedKeys",
2526 "AddAuthorizedKey",
2527 "RemoveAuthorizedKey",
2528 ] {
2529 for bad in ["../../etc", "..", "root/../../etc"] {
2530 let err = build_action_spec(
2531 action,
2532 &json!({
2533 "username": bad,
2534 "public_key": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample u@h",
2535 }),
2536 )
2537 .unwrap_err();
2538 assert!(
2539 matches!(err, ExecutorError::InvalidParam("username")),
2540 "{action} must reject username {bad:?}, got {err:?}"
2541 );
2542 }
2543 }
2544 }
2545
2546 #[test]
2547 fn ip_taking_actions_reject_a_malformed_address() {
2548 let cases: [(&str, serde_json::Value); 3] = [
2551 (
2552 "ResolvectlSetDns",
2553 json!({ "interface": "eth0", "servers": ["not-an-ip"] }),
2554 ),
2555 (
2556 "Fail2banBanIp",
2557 json!({ "jail": "sshd", "ip": "999.1.1.1" }),
2558 ),
2559 (
2560 "Fail2banUnbanIp",
2561 json!({ "jail": "sshd", "ip": "1.2.3.4.5" }),
2562 ),
2563 ];
2564 for (action, params) in cases {
2565 let err = build_action_spec(action, ¶ms).unwrap_err();
2566 assert!(
2567 matches!(err, ExecutorError::InvalidIpAddress { .. }),
2568 "{action} must reject a malformed IP, got {err:?}"
2569 );
2570 }
2571 }
2572
2573 #[test]
2574 fn public_key_rejects_pipe_metacharacter() {
2575 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
2579 assert!(matches!(
2580 validated_public_key(key),
2581 Err(ExecutorError::InvalidParam("public_key"))
2582 ));
2583 }
2584
2585 #[test]
2586 fn public_key_accepts_regex_metacharacters_so_consumers_must_not_use_regex() {
2587 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIExample alice@example.com";
2594 assert!(validated_public_key(key).is_ok());
2595 assert!(validated_public_key("ssh-ed25519 .*").is_ok());
2598 }
2599
2600 #[test]
2601 fn public_key_rejects_shell_metacharacters() {
2602 for (metachar, desc) in [
2603 (';', "semicolon"),
2604 ('`', "backtick"),
2605 ('$', "dollar"),
2606 ('\\', "backslash"),
2607 ('&', "ampersand"),
2608 ] {
2609 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
2610 assert!(
2611 matches!(
2612 validated_public_key(&key),
2613 Err(ExecutorError::InvalidParam("public_key"))
2614 ),
2615 "{desc} should be rejected"
2616 );
2617 }
2618 }
2619
2620 #[test]
2621 fn public_key_rejects_newline() {
2622 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
2623 assert!(matches!(
2624 validated_public_key(key),
2625 Err(ExecutorError::InvalidParam("public_key"))
2626 ));
2627 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
2628 assert!(matches!(
2629 validated_public_key(key_cr),
2630 Err(ExecutorError::InvalidParam("public_key"))
2631 ));
2632 }
2633
2634 #[test]
2635 fn public_key_rejects_too_long() {
2636 let long_payload = "A".repeat(8192);
2638 let key = format!("ssh-ed25519 {long_payload} user@host");
2639 assert!(matches!(
2640 validated_public_key(&key),
2641 Err(ExecutorError::InvalidParam("public_key"))
2642 ));
2643 }
2644
2645 #[test]
2648 fn str_array_or_empty_rejects_non_string_element() {
2649 let params = json!({ "packages": ["vim", 42, "curl"] });
2650 assert!(matches!(
2651 str_array_or_empty(¶ms, "packages"),
2652 Err(ExecutorError::InvalidParam("packages"))
2653 ));
2654 }
2655
2656 #[test]
2657 fn str_array_or_empty_accepts_string_array() {
2658 let params = json!({ "packages": ["vim", "curl"] });
2659 assert_eq!(
2660 str_array_or_empty(¶ms, "packages").unwrap(),
2661 vec!["vim".to_string(), "curl".to_string()]
2662 );
2663 }
2664
2665 #[test]
2666 fn str_array_or_empty_returns_empty_when_key_absent() {
2667 let params = json!({});
2668 assert_eq!(
2669 str_array_or_empty(¶ms, "packages").unwrap(),
2670 Vec::<String>::new()
2671 );
2672 }
2673
2674 #[test]
2677 fn kernel_arg_allows_safe_args() {
2678 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
2679 assert!(validated_safe_kernel_arg("splash", "add").is_ok());
2680 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
2681 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
2682 }
2683
2684 #[test]
2685 fn kernel_arg_blocks_init_override() {
2686 assert!(matches!(
2687 validated_safe_kernel_arg("init=/bin/sh", "add"),
2688 Err(ExecutorError::InvalidParam("add"))
2689 ));
2690 assert!(matches!(
2691 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
2692 Err(ExecutorError::InvalidParam("add"))
2693 ));
2694 }
2695
2696 #[test]
2697 fn kernel_arg_blocks_selinux_disable() {
2698 assert!(matches!(
2699 validated_safe_kernel_arg("selinux=0", "add"),
2700 Err(ExecutorError::InvalidParam("add"))
2701 ));
2702 assert!(matches!(
2703 validated_safe_kernel_arg("enforcing=0", "add"),
2704 Err(ExecutorError::InvalidParam("add"))
2705 ));
2706 }
2707
2708 #[test]
2709 fn kernel_arg_blocks_security_override() {
2710 assert!(matches!(
2711 validated_safe_kernel_arg("security=none", "add"),
2712 Err(ExecutorError::InvalidParam("add"))
2713 ));
2714 }
2715
2716 #[test]
2717 fn kernel_arg_blocks_module_blacklist() {
2718 assert!(matches!(
2719 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
2720 Err(ExecutorError::InvalidParam("add"))
2721 ));
2722 }
2723
2724 #[test]
2725 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
2726 assert!(matches!(
2727 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
2728 Err(ExecutorError::InvalidParam("add"))
2729 ));
2730 assert!(matches!(
2731 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
2732 Err(ExecutorError::InvalidParam("add"))
2733 ));
2734 assert!(matches!(
2735 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
2736 Err(ExecutorError::InvalidParam("add"))
2737 ));
2738 }
2739
2740 #[test]
2741 fn kernel_arg_blocks_apparmor_disable() {
2742 assert!(matches!(
2743 validated_safe_kernel_arg("apparmor=0", "add"),
2744 Err(ExecutorError::InvalidParam("add"))
2745 ));
2746 }
2747
2748 #[test]
2749 fn kernel_arg_blocks_mitigations_off() {
2750 assert!(matches!(
2751 validated_safe_kernel_arg("mitigations=off", "add"),
2752 Err(ExecutorError::InvalidParam("add"))
2753 ));
2754 }
2755
2756 #[test]
2757 fn kernel_arg_blocks_lockdown_override() {
2758 assert!(matches!(
2759 validated_safe_kernel_arg("lockdown=none", "add"),
2760 Err(ExecutorError::InvalidParam("add"))
2761 ));
2762 }
2763
2764 #[test]
2765 fn kernel_arg_blocks_pti_off() {
2766 assert!(matches!(
2767 validated_safe_kernel_arg("pti=off", "add"),
2768 Err(ExecutorError::InvalidParam("add"))
2769 ));
2770 }
2771
2772 #[test]
2773 fn kernel_arg_blocks_smap_smep_disable() {
2774 assert!(matches!(
2775 validated_safe_kernel_arg("nosmap", "add"),
2776 Err(ExecutorError::InvalidParam("add"))
2777 ));
2778 assert!(matches!(
2779 validated_safe_kernel_arg("nosmep", "add"),
2780 Err(ExecutorError::InvalidParam("add"))
2781 ));
2782 }
2783
2784 #[test]
2785 fn kernel_arg_blocks_single_user_shortcuts() {
2786 assert!(matches!(
2788 validated_safe_kernel_arg("single", "add"),
2789 Err(ExecutorError::InvalidParam("add"))
2790 ));
2791 assert!(matches!(
2792 validated_safe_kernel_arg("s", "add"),
2793 Err(ExecutorError::InvalidParam("add"))
2794 ));
2795 assert!(matches!(
2796 validated_safe_kernel_arg("1", "add"),
2797 Err(ExecutorError::InvalidParam("add"))
2798 ));
2799 }
2800
2801 #[test]
2802 fn signal_process_guardrails() {
2803 for bad_pid in [0, 1] {
2805 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
2806 assert!(
2807 matches!(err, ExecutorError::InvalidParam("pid")),
2808 "pid {bad_pid} must be rejected, got {err:?}"
2809 );
2810 }
2811 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
2813 .unwrap_err();
2814 assert!(
2815 matches!(err, ExecutorError::InvalidParam("signal")),
2816 "got {err:?}"
2817 );
2818 assert!(matches!(
2820 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
2821 ExecutorError::MissingParam("pid")
2822 ));
2823 let spec = build_action_spec(
2825 "SignalProcess",
2826 &json!({ "pid": "4242", "signal": "sigkill" }),
2827 )
2828 .unwrap();
2829 assert_eq!(spec.action_name, "SignalProcess");
2830 assert_eq!(
2831 spec.mechanism,
2832 ActionMechanism::Command {
2833 program: "sudo",
2834 args: vec![
2835 "kill".to_string(),
2836 "-s".to_string(),
2837 "KILL".to_string(),
2838 "4242".to_string()
2839 ],
2840 }
2841 );
2842 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
2844 if let ActionMechanism::Command { args, .. } = spec.mechanism {
2845 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
2846 } else {
2847 panic!("expected Command mechanism");
2848 }
2849 }
2850
2851 #[test]
2852 fn create_scheduled_job_validates_name_command_schedule() {
2853 assert!(matches!(
2855 build_action_spec(
2856 "CreateScheduledJob",
2857 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
2858 )
2859 .unwrap_err(),
2860 ExecutorError::InvalidParam("command")
2861 ));
2862 assert!(matches!(
2864 build_action_spec(
2865 "CreateScheduledJob",
2866 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
2867 )
2868 .unwrap_err(),
2869 ExecutorError::InvalidParam("name")
2870 ));
2871 assert!(matches!(
2873 build_action_spec(
2874 "CreateScheduledJob",
2875 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
2876 )
2877 .unwrap_err(),
2878 ExecutorError::InvalidParam("schedule")
2879 ));
2880 let spec = build_action_spec(
2882 "CreateScheduledJob",
2883 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
2884 )
2885 .unwrap();
2886 assert_eq!(
2887 spec.mechanism,
2888 ActionMechanism::Command {
2889 program: "sudo",
2890 args: vec![
2891 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
2892 "--name".to_string(),
2893 "nightly-backup".to_string(),
2894 "--command".to_string(),
2895 "/usr/bin/backup --full".to_string(),
2896 "--schedule".to_string(),
2897 "*-*-* 02:00:00".to_string(),
2898 ],
2899 }
2900 );
2901 }
2902
2903 #[test]
2904 fn set_sshd_option_allowlist_guardrails() {
2905 assert!(matches!(
2907 build_action_spec(
2908 "SetSshdOption",
2909 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
2910 )
2911 .unwrap_err(),
2912 ExecutorError::InvalidParam("option")
2913 ));
2914 assert!(matches!(
2916 build_action_spec(
2917 "SetSshdOption",
2918 &json!({ "option": "PermitRootLogin", "value": "maybe" })
2919 )
2920 .unwrap_err(),
2921 ExecutorError::InvalidParam("value")
2922 ));
2923 let spec = build_action_spec(
2925 "SetSshdOption",
2926 &json!({ "option": "PasswordAuthentication", "value": "no" }),
2927 )
2928 .unwrap();
2929 assert_eq!(
2930 spec.mechanism,
2931 ActionMechanism::Command {
2932 program: "sudo",
2933 args: vec![
2934 "/usr/lib/sysknife/sshd-option-edit".to_string(),
2935 "--option".to_string(),
2936 "PasswordAuthentication".to_string(),
2937 "--value".to_string(),
2938 "no".to_string(),
2939 ],
2940 }
2941 );
2942 }
2943
2944 #[test]
2945 fn configure_unattended_upgrades_toggles_helper_flag() {
2946 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
2947 let spec = build_action_spec(
2948 "ConfigureUnattendedUpgrades",
2949 &json!({ "enabled": enabled }),
2950 )
2951 .unwrap();
2952 assert_eq!(
2953 spec.mechanism,
2954 ActionMechanism::Command {
2955 program: "sudo",
2956 args: vec![
2957 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
2958 flag.to_string(),
2959 ],
2960 }
2961 );
2962 }
2963 assert!(matches!(
2964 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
2965 ExecutorError::MissingParam("enabled")
2966 ));
2967 }
2968
2969 #[test]
2970 fn grub_set_kargs_append_blocks_single_user_shortcut() {
2971 for dangerous in ["single", "s", "1", "nosmap", "nosmep"] {
2979 let err = build_action_spec(
2980 "GrubSetKargs",
2981 &json!({ "append": [dangerous], "delete": [] }),
2982 )
2983 .unwrap_err();
2984 assert!(
2985 matches!(err, ExecutorError::InvalidParam("append")),
2986 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
2987 );
2988 }
2989 assert!(build_action_spec(
2991 "GrubSetKargs",
2992 &json!({ "append": ["quiet"], "delete": [] })
2993 )
2994 .is_ok());
2995 }
2996
2997 #[test]
2998 fn kernel_arg_build_spec_rejects_dangerous_arg() {
2999 let err = build_action_spec(
3005 "SetKernelArguments",
3006 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
3007 )
3008 .unwrap_err();
3009 assert!(
3010 matches!(err, ExecutorError::InvalidParam("add")),
3011 "expected InvalidParam(add), got {err:?}"
3012 );
3013 }
3014
3015 #[test]
3016 fn grub_set_kargs_cannot_delete_a_protective_karg() {
3017 for karg in [
3024 "apparmor=1",
3025 "lockdown=confidentiality",
3026 "mitigations=auto",
3027 "pti=on",
3028 "selinux=1",
3029 ] {
3030 let err = build_action_spec("GrubSetKargs", &json!({ "append": [], "delete": [karg] }))
3031 .unwrap_err();
3032 assert!(
3033 matches!(err, ExecutorError::InvalidParam("delete")),
3034 "deleting {karg:?} must be refused, got {err:?}"
3035 );
3036 }
3037 }
3038
3039 #[test]
3044 fn rollback_available_matches_rollback_spec_for_all_actions() {
3045 for spec in crate::actions::all_specs() {
3053 let has_rollback = rollback_spec_for(spec.action_name).is_some();
3054 assert_eq!(
3055 spec.rollback_available,
3056 has_rollback,
3057 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
3058 spec.action_name,
3059 spec.rollback_available,
3060 if has_rollback { "Some" } else { "None" },
3061 );
3062 }
3063 }
3064
3065 #[test]
3071 fn create_user_is_high_risk() {
3072 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
3073 assert_eq!(
3074 spec.risk_level,
3075 RiskLevel::High,
3076 "CreateUser must be High (T1136.001 Persistence)"
3077 );
3078 }
3079
3080 #[test]
3081 fn configure_firewall_is_high_risk() {
3082 let spec = build_action_spec(
3083 "ConfigureFirewall",
3084 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
3085 )
3086 .unwrap();
3087 assert_eq!(
3088 spec.risk_level,
3089 RiskLevel::High,
3090 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
3091 );
3092 }
3093
3094 #[test]
3095 fn mask_service_is_high_risk() {
3096 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
3097 assert_eq!(
3098 spec.risk_level,
3099 RiskLevel::High,
3100 "MaskService must be High (T1562.001 Impair Defenses)"
3101 );
3102 }
3103
3104 #[test]
3105 fn add_package_repository_is_high_risk() {
3106 let spec = build_action_spec(
3107 "AddPackageRepository",
3108 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
3109 )
3110 .unwrap();
3111 assert_eq!(
3112 spec.risk_level,
3113 RiskLevel::High,
3114 "AddPackageRepository must be High (supply-chain vector)"
3115 );
3116 }
3117
3118 #[test]
3119 fn set_dns_servers_is_high_risk() {
3120 let spec = build_action_spec(
3121 "SetDnsServers",
3122 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
3123 )
3124 .unwrap();
3125 assert_eq!(
3126 spec.risk_level,
3127 RiskLevel::High,
3128 "SetDnsServers must be High (DNS hijacking / T1557)"
3129 );
3130 }
3131}