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