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, validated_hostname,
11 validated_journal_grep, validated_journal_priority, validated_journal_time,
12 validated_locale, validated_log_path, validated_lvm_name, validated_lvm_size,
13 validated_memory_limit, validated_mount_device, validated_mount_options,
14 validated_mount_point, validated_port_or_service, validated_ppa_name,
15 validated_pro_service, validated_safe_arg, validated_sudo_commands, validated_sudoers_name,
16 validated_swap_path, validated_sysctl_key, validated_sysctl_value, validated_syslog_host,
17 validated_tasks_max, validated_timezone, validated_unit_name, validated_username,
18 },
19 ActionMechanism, ActionSpec,
20};
21use async_trait::async_trait;
22use serde_json::Value;
23use std::io;
24use std::net::IpAddr;
25use std::process::Stdio;
26use std::str::FromStr;
27use tokio::io::{AsyncBufReadExt, AsyncReadExt, BufReader};
28use tokio::sync::mpsc::UnboundedSender;
29
30#[derive(Debug, thiserror::Error)]
31pub enum ExecutorError {
32 #[error("unknown action: {0}")]
33 UnknownAction(String),
34
35 #[error("missing required param: {0}")]
36 MissingParam(&'static str),
37
38 #[error("invalid param type for: {0}")]
39 InvalidParam(&'static str),
40
41 #[error("invalid IP address for param '{param}': '{value}'")]
47 InvalidIpAddress { param: &'static str, value: String },
48
49 #[error("io error: {0}")]
50 Io(#[from] io::Error),
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
62pub struct ExecutionOutput {
63 pub stdout: String,
64 pub stderr: String,
65 pub exit_code: i32,
66}
67
68impl ExecutionOutput {
69 pub fn is_success(&self) -> bool {
71 self.exit_code == 0
72 }
73
74 pub fn is_nonzero(&self) -> bool {
76 self.exit_code != 0
77 }
78}
79
80#[async_trait]
87pub trait ActionExecutor: Send + Sync {
88 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError>;
90
91 async fn execute_with_progress(
97 &self,
98 spec: &ActionSpec,
99 progress: UnboundedSender<String>,
100 ) -> Result<ExecutionOutput, ExecutorError> {
101 let output = self.execute(spec).await?;
102 for line in output.stdout.lines().filter(|line| !line.is_empty()) {
103 let _ = progress.send(line.to_string());
104 }
105 Ok(output)
106 }
107}
108
109pub struct RealActionExecutor;
111
112#[async_trait]
113impl ActionExecutor for RealActionExecutor {
114 async fn execute(&self, spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
115 execute_spec(spec).await
116 }
117
118 async fn execute_with_progress(
119 &self,
120 spec: &ActionSpec,
121 progress: UnboundedSender<String>,
122 ) -> Result<ExecutionOutput, ExecutorError> {
123 match &spec.mechanism {
124 ActionMechanism::Command { program, args } => {
125 execute_command_with_progress(program, args, progress).await
126 }
127 _ => execute_spec(spec).await,
128 }
129 }
130}
131
132async fn execute_command_with_progress(
133 program: &'static str,
134 args: &[String],
135 progress: UnboundedSender<String>,
136) -> Result<ExecutionOutput, ExecutorError> {
137 let mut child = tokio::process::Command::new(program)
138 .args(args)
139 .stdout(Stdio::piped())
140 .stderr(Stdio::piped())
141 .spawn()
142 .map_err(ExecutorError::Io)?;
143
144 let stdout = child.stdout.take().expect("stdout was piped");
145 let stderr = child.stderr.take().expect("stderr was piped");
146 let stderr_task = tokio::spawn(async move {
147 let mut buf = Vec::new();
148 BufReader::new(stderr)
149 .read_to_end(&mut buf)
150 .await
151 .map(|_| buf)
152 });
153
154 let mut lines = BufReader::new(stdout).lines();
155 let mut stdout_buf = String::new();
156 while let Some(line) = lines.next_line().await.map_err(ExecutorError::Io)? {
157 if !line.is_empty() {
158 let _ = progress.send(line.clone());
159 }
160 stdout_buf.push_str(&line);
161 stdout_buf.push('\n');
162 }
163
164 let exit_status = child.wait().await.map_err(ExecutorError::Io)?;
165 let stderr_bytes = stderr_task
166 .await
167 .map_err(|_| ExecutorError::Io(io::Error::other("stderr reader task panicked")))?
168 .map_err(ExecutorError::Io)?;
169
170 Ok(ExecutionOutput {
171 stdout: stdout_buf,
172 stderr: String::from_utf8_lossy(&stderr_bytes).into_owned(),
173 exit_code: exit_status.code().unwrap_or(-1),
174 })
175}
176
177pub fn build_action_spec(action_name: &str, params: &Value) -> Result<ActionSpec, ExecutorError> {
182 match action_name {
183 "GetSystemState" => Ok(deployment::get_system_state()),
185 "CollectDiagnostics" => Ok(deployment::collect_diagnostics()),
186 "GetDeploymentHistory" => Ok(deployment::get_deployment_history()),
187 "ListDeployments" => Ok(deployment::list_deployments()),
188 "UpdateSystem" => Ok(deployment::update_system()),
189 "CleanupDeployments" => Ok(deployment::cleanup_deployments()),
190 "RebootSystem" => Ok(deployment::reboot_system()),
191 "RollbackDeployment" => Ok(deployment::rollback_deployment()),
192 "GetKernelArguments" => Ok(deployment::get_kernel_arguments()),
193
194 "PinDeployment" => Ok(deployment::pin_deployment(require_u32(params, "index")?)),
196 "UnpinDeployment" => Ok(deployment::unpin_deployment(require_u32(params, "index")?)),
197 "RebaseSystem" => {
198 let target_ref = require_str(params, "target_ref")?;
199 let target_ref = validated_safe_arg(target_ref, "target_ref")?;
200 Ok(deployment::rebase_system(&target_ref))
201 }
202 "SetKernelArguments" => {
203 let add = str_array_or_empty(params, "add")?;
204 let remove = str_array_or_empty(params, "remove")?;
205 for arg in add.iter() {
208 validated_safe_kernel_arg(arg, "add")?;
209 }
210 let add_refs: Vec<&str> = add.iter().map(String::as_str).collect();
211 let remove_refs: Vec<&str> = remove.iter().map(String::as_str).collect();
212 Ok(deployment::set_kernel_arguments(&add_refs, &remove_refs))
213 }
214
215 "ListFlatpakRemotes" => {
221 let username = validated_username(resolve_username(params)?, "username")?;
222 Ok(flatpak::list_flatpak_remotes(&username))
223 }
224 "InstallFlatpak" => {
225 let username = validated_username(resolve_username(params)?, "username")?;
226 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
227 let remote = params
231 .get("remote")
232 .and_then(|v| v.as_str())
233 .unwrap_or("flathub");
234 let remote = validated_safe_arg(remote, "remote")?;
235 Ok(flatpak::install_flatpak(&username, &app_id, &remote))
236 }
237 "RemoveFlatpak" => {
238 let username = validated_username(resolve_username(params)?, "username")?;
239 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
240 Ok(flatpak::remove_flatpak(&username, &app_id))
241 }
242 "SearchFlatpakApps" => {
243 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
244 Ok(flatpak::search_flatpak_apps(&term))
245 }
246 "AddFlatpakRemote" => {
247 let username = validated_username(resolve_username(params)?, "username")?;
248 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
249 let url = validated_safe_arg(require_str(params, "url")?, "url")?;
250 Ok(flatpak::add_flatpak_remote(&username, &remote, &url))
251 }
252 "RemoveFlatpakRemote" => {
253 let username = validated_username(resolve_username(params)?, "username")?;
254 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
255 Ok(flatpak::remove_flatpak_remote(&username, &remote))
256 }
257 "GetFlatpakAppInfo" => {
258 let username = validated_username(resolve_username(params)?, "username")?;
259 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
260 Ok(flatpak::get_flatpak_app_info(&username, &app_id))
261 }
262 "ListInstalledFlatpaks" => {
263 let username = validated_username(resolve_username(params)?, "username")?;
264 Ok(flatpak::list_installed_flatpaks(&username))
265 }
266 "UpdateFlatpak" => {
267 let username = validated_username(resolve_username(params)?, "username")?;
268 let app_id = params
271 .get("app_id")
272 .and_then(|v| v.as_str())
273 .filter(|id| !id.is_empty())
274 .map(|id| validated_safe_arg(id, "app_id"))
275 .transpose()?;
276 Ok(flatpak::update_flatpak(&username, app_id.as_deref()))
277 }
278
279 "ListContainers" => {
285 let username = validated_username(resolve_username(params)?, "username")?;
286 Ok(containers::list_containers(&username))
287 }
288 "CreateContainer" => {
289 let username = validated_username(resolve_username(params)?, "username")?;
290 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
291 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
292 Ok(containers::create_container(&username, &name, &image))
293 }
294 "StartContainer" => {
295 let username = validated_username(resolve_username(params)?, "username")?;
296 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
297 Ok(containers::start_container(&username, &name))
298 }
299 "StopContainer" => {
300 let username = validated_username(resolve_username(params)?, "username")?;
301 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
302 Ok(containers::stop_container(&username, &name))
303 }
304 "RemoveContainer" => {
305 let username = validated_username(resolve_username(params)?, "username")?;
306 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
307 Ok(containers::remove_container(&username, &name))
308 }
309 "GetContainerInfo" => {
310 let username = validated_username(resolve_username(params)?, "username")?;
311 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
312 Ok(containers::get_container_info(&username, &name))
313 }
314
315 "GetLayeredPackages" => Ok(layering::get_layered_packages()),
317 "ResetLayeredPackageOverride" => Ok(layering::reset_layered_package_override()),
318 "InstallPackages" => {
319 let pkgs = str_array_or_empty(params, "packages")?;
320 let validated: Vec<String> = pkgs
321 .iter()
322 .map(|p| validated_safe_arg(p, "packages"))
323 .collect::<Result<_, _>>()?;
324 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
325 Ok(layering::install_packages(&refs))
326 }
327 "RemovePackages" => {
328 let pkgs = str_array_or_empty(params, "packages")?;
329 let validated: Vec<String> = pkgs
330 .iter()
331 .map(|p| validated_safe_arg(p, "packages"))
332 .collect::<Result<_, _>>()?;
333 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
334 Ok(layering::remove_packages(&refs))
335 }
336 "AddLayeredPackage" => {
337 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
338 Ok(layering::add_layered_package(&package))
339 }
340 "RemoveLayeredPackage" => {
341 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
342 Ok(layering::remove_layered_package(&package))
343 }
344 "ReplaceLayeredPackage" => {
345 let old = validated_safe_arg(require_str(params, "old")?, "old")?;
346 let new = validated_safe_arg(require_str(params, "new")?, "new")?;
347 Ok(layering::replace_layered_package(&old, &new))
348 }
349 "RemoveBasePackage" => {
350 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
351 Ok(layering::remove_base_package(&package))
352 }
353 "GetPendingUpdates" => Ok(layering::get_pending_updates()),
354
355 "ListPackageRepositories" => Ok(package_repos::list_package_repositories()),
357 "AddPackageRepository" => Ok(package_repos::add_package_repository(
358 validated_repo_id(params)?,
359 validated_no_newline(params, "repo_url")?,
360 )),
361 "RemovePackageRepository" => Ok(package_repos::remove_package_repository(
362 validated_repo_id(params)?,
363 )),
364 "EnablePackageRepository" => Ok(package_repos::enable_package_repository(
365 validated_repo_id(params)?,
366 )),
367 "DisablePackageRepository" => Ok(package_repos::disable_package_repository(
368 validated_repo_id(params)?,
369 )),
370
371 "ListServices" => Ok(services::list_services()),
373 "StartService" => {
374 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
375 Ok(services::start_service(&unit))
376 }
377 "StopService" => {
378 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
379 Ok(services::stop_service(&unit))
380 }
381 "RestartService" => {
382 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
383 Ok(services::restart_service(&unit))
384 }
385 "SetServiceEnabled" => {
386 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
387 Ok(services::set_service_enabled(
388 &unit,
389 require_bool(params, "enabled")?,
390 ))
391 }
392 "MaskService" => {
393 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
394 Ok(services::mask_service(&unit))
395 }
396 "UnmaskService" => {
397 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
398 Ok(services::unmask_service(&unit))
399 }
400 "GetServiceLogs" => {
401 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
402 Ok(services::get_service_logs(&unit))
403 }
404 "GetServiceStatus" => {
405 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
406 Ok(services::get_service_status(&unit))
407 }
408 "ReloadService" => {
409 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
410 Ok(services::reload_service(&unit))
411 }
412 "ListTimers" => Ok(services::list_timers()),
413 "ReloadDaemon" => Ok(services::reload_daemon()),
414 "CreateScheduledJob" => {
415 let name = require_str(params, "name")?;
417 if name.is_empty()
418 || name.len() > 64
419 || !name
420 .chars()
421 .next()
422 .is_some_and(|c| c.is_ascii_alphanumeric())
423 || !name
424 .chars()
425 .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
426 {
427 return Err(ExecutorError::InvalidParam("name"));
428 }
429 let command = require_str(params, "command")?;
432 if command.is_empty() || command.len() > 512 || command.chars().any(|c| c.is_control())
433 {
434 return Err(ExecutorError::InvalidParam("command"));
435 }
436 let schedule = require_str(params, "schedule")?;
439 if schedule.is_empty()
440 || schedule.len() > 128
441 || !schedule.chars().all(|c| {
442 c.is_ascii_alphanumeric()
443 || matches!(c, ' ' | ':' | ',' | '*' | '/' | '.' | '~' | '+' | '-')
444 })
445 {
446 return Err(ExecutorError::InvalidParam("schedule"));
447 }
448 Ok(services::create_scheduled_job(name, command, schedule))
449 }
450 "GetServiceResourceLimits" => {
451 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
452 Ok(services::get_service_resource_limits(&unit))
453 }
454 "SetServiceResourceLimits" => {
455 let unit = validated_unit_name(require_str(params, "unit")?, "unit")?;
456 let mut assignments = Vec::new();
459 if let Some(v) = optional_validated(params, "memory_max", validated_memory_limit)? {
460 assignments.push(format!("MemoryMax={v}"));
461 }
462 if let Some(v) = optional_validated(params, "memory_high", validated_memory_limit)? {
463 assignments.push(format!("MemoryHigh={v}"));
464 }
465 if let Some(v) = optional_validated(params, "cpu_quota", validated_cpu_quota)? {
466 assignments.push(format!("CPUQuota={v}"));
467 }
468 if let Some(v) = optional_validated(params, "tasks_max", validated_tasks_max)? {
469 assignments.push(format!("TasksMax={v}"));
470 }
471 if assignments.is_empty() {
472 return Err(ExecutorError::MissingParam("memory_max"));
473 }
474 Ok(services::set_service_resource_limits(&unit, &assignments))
475 }
476
477 "ListToolboxes" => {
481 let username = validated_username(resolve_username(params)?, "username")?;
482 Ok(toolbox::list_toolboxes(&username))
483 }
484 "CreateToolbox" => {
485 let username = validated_username(resolve_username(params)?, "username")?;
486 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
487 let image = params
488 .get("image")
489 .and_then(|v| v.as_str())
490 .map(|img| validated_safe_arg(img, "image"))
491 .transpose()?;
492 let release = params
493 .get("release")
494 .and_then(|v| v.as_str())
495 .map(|r| validated_safe_arg(r, "release"))
496 .transpose()?;
497 Ok(toolbox::create_toolbox(
498 &username,
499 &name,
500 release.as_deref(),
501 image.as_deref(),
502 ))
503 }
504 "RemoveToolbox" => {
505 let username = validated_username(resolve_username(params)?, "username")?;
506 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
507 Ok(toolbox::remove_toolbox(&username, &name))
508 }
509
510 "GetDateTime" => Ok(identity::get_datetime()),
512 "SetHostname" => {
513 let hostname = validated_hostname(require_str(params, "hostname")?, "hostname")?;
514 Ok(identity::set_hostname(&hostname))
515 }
516 "SetTimezone" => {
517 let timezone = validated_timezone(require_str(params, "timezone")?, "timezone")?;
518 Ok(identity::set_timezone(&timezone))
519 }
520 "SetLocale" => {
521 let locale = validated_locale(require_str(params, "locale")?, "locale")?;
522 Ok(identity::set_locale(&locale))
523 }
524 "SetNtp" => Ok(identity::set_ntp(require_bool(params, "enabled")?)),
525
526 "GetDiskUsage" => Ok(filesystem::disk_usage_spec()),
528
529 "ListProcesses" => Ok(processes::list_processes_spec()),
531 "SignalProcess" => {
532 let pid = params
534 .get("pid")
535 .and_then(|v| {
536 v.as_u64()
537 .or_else(|| v.as_str().and_then(|s| s.trim().parse::<u64>().ok()))
538 })
539 .ok_or(ExecutorError::MissingParam("pid"))?;
540 if pid < 2 || pid > u32::MAX as u64 {
543 return Err(ExecutorError::InvalidParam("pid"));
544 }
545 let signal = validated_kill_signal(
546 params
547 .get("signal")
548 .and_then(|v| v.as_str())
549 .unwrap_or("TERM"),
550 )?;
551 Ok(processes::signal_process(pid as u32, signal))
552 }
553
554 "GetJournalLog" => {
556 let unit = optional_validated(params, "unit", validated_unit_name)?;
557 let priority = optional_validated(params, "priority", validated_journal_priority)?;
558 let since = optional_validated(params, "since", validated_journal_time)?;
559 let until = optional_validated(params, "until", validated_journal_time)?;
560 let grep = optional_validated(params, "grep", validated_journal_grep)?;
561 let lines = params
564 .get("lines")
565 .and_then(|v| v.as_u64())
566 .unwrap_or(100)
567 .clamp(1, 10_000) as u32;
568 let boot = params
569 .get("boot")
570 .and_then(|v| v.as_bool())
571 .unwrap_or(false);
572 let kernel = params
573 .get("kernel")
574 .and_then(|v| v.as_bool())
575 .unwrap_or(false);
576 Ok(journald::get_journal_log(&journald::JournalQuery {
577 lines,
578 unit: unit.as_deref(),
579 priority: priority.as_deref(),
580 boot,
581 kernel,
582 since: since.as_deref(),
583 until: until.as_deref(),
584 grep: grep.as_deref(),
585 }))
586 }
587 "VacuumJournal" => {
588 let size_mb = params.get("size_mb").and_then(|v| v.as_u64());
590 let retain_days = params.get("retain_days").and_then(|v| v.as_u64());
591 match (size_mb, retain_days) {
592 (Some(mb), None) if (1..=u32::MAX as u64).contains(&mb) => {
593 Ok(journald::vacuum_journal_by_size(mb as u32))
594 }
595 (None, Some(days)) if (1..=u32::MAX as u64).contains(&days) => {
596 Ok(journald::vacuum_journal_by_time(days as u32))
597 }
598 (None, None) => Err(ExecutorError::MissingParam("size_mb")),
599 _ => Err(ExecutorError::InvalidParam("size_mb")),
601 }
602 }
603
604 "GetLvmReport" => Ok(lvm::get_lvm_report()),
606 "ExtendLogicalVolume" => {
607 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
608 let lv = validated_lvm_name(require_str(params, "lv")?, "lv")?;
609 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
610 Ok(lvm::extend_logical_volume(&vg, &lv, &size))
611 }
612 "CreateLogicalVolume" => {
613 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
614 let name = validated_lvm_name(require_str(params, "name")?, "name")?;
615 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
616 Ok(lvm::create_logical_volume(&vg, &name, &size))
617 }
618 "CreateLvSnapshot" => {
619 let vg = validated_lvm_name(require_str(params, "vg")?, "vg")?;
620 let origin = validated_lvm_name(require_str(params, "origin")?, "origin")?;
621 let snapshot = validated_lvm_name(require_str(params, "snapshot")?, "snapshot")?;
622 let size = validated_lvm_size(require_str(params, "size")?, "size")?;
623 Ok(lvm::create_lv_snapshot(&vg, &origin, &snapshot, &size))
624 }
625
626 "GetSysctl" => {
628 let key = optional_validated(params, "key", validated_sysctl_key)?;
630 Ok(sysctl::get_sysctl(key.as_deref()))
631 }
632 "SetSysctl" => {
633 let key = validated_sysctl_key(require_str(params, "key")?, "key")?;
634 let value = validated_sysctl_value(require_str(params, "value")?, "value")?;
635 Ok(sysctl::set_sysctl(&key, &value))
636 }
637
638 "GetMounts" => Ok(mounts::get_mounts()),
640 "AddMount" => {
641 let device = validated_mount_device(require_str(params, "device")?, "device")?;
642 let mountpoint =
643 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
644 let fstype = validated_fstype(require_str(params, "fstype")?, "fstype")?;
645 let options = optional_validated(params, "options", validated_mount_options)?;
646 Ok(mounts::add_mount(
647 &device,
648 &mountpoint,
649 &fstype,
650 options.as_deref(),
651 ))
652 }
653 "RemoveMount" => {
654 let mountpoint =
655 validated_mount_point(require_str(params, "mountpoint")?, "mountpoint")?;
656 Ok(mounts::remove_mount(&mountpoint))
657 }
658 "AddSwap" => {
659 let file = validated_swap_path(require_str(params, "file")?, "file")?;
660 let size_mb = require_u32(params, "size_mb")?;
661 if !(1..=1_048_576).contains(&size_mb) {
663 return Err(ExecutorError::InvalidParam("size_mb"));
664 }
665 Ok(mounts::add_swap(&file, size_mb))
666 }
667 "RemoveSwap" => {
668 let file = validated_swap_path(require_str(params, "file")?, "file")?;
669 Ok(mounts::remove_swap(&file))
670 }
671
672 "GetSudoGrants" => Ok(sudoers::get_sudo_grants()),
674 "GrantSudoAccess" => {
675 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
676 let user = validated_username(require_str(params, "user")?, "user")?;
677 let commands = validated_sudo_commands(require_str(params, "commands")?, "commands")?;
678 let runas = match params
680 .get("runas")
681 .and_then(|v| v.as_str())
682 .filter(|s| !s.is_empty())
683 {
684 Some("ALL") => Some("ALL".to_string()),
685 Some(u) => Some(validated_username(u, "runas")?),
686 None => None,
687 };
688 let nopasswd = params
689 .get("nopasswd")
690 .and_then(|v| v.as_bool())
691 .unwrap_or(false);
692 Ok(sudoers::grant_sudo_access(
693 &name,
694 &user,
695 &commands,
696 runas.as_deref(),
697 nopasswd,
698 ))
699 }
700 "RevokeSudoAccess" => {
701 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
702 Ok(sudoers::revoke_sudo_access(&name))
703 }
704
705 "GetAptPins" => {
707 let package = optional_validated(params, "package", validated_apt_package)?;
708 Ok(apt_preferences::get_apt_pins(package.as_deref()))
709 }
710 "SetAptPin" => {
711 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
712 let package = validated_apt_package(require_str(params, "package")?, "package")?;
713 let pin = validated_apt_pin_expr(require_str(params, "pin")?, "pin")?;
714 let priority = params
715 .get("priority")
716 .and_then(|v| v.as_i64())
717 .ok_or(ExecutorError::MissingParam("priority"))?;
718 if !(-1..=1000).contains(&priority) {
719 return Err(ExecutorError::InvalidParam("priority"));
720 }
721 Ok(apt_preferences::set_apt_pin(
722 &name, &package, &pin, priority,
723 ))
724 }
725 "RemoveAptPin" => {
726 let name = validated_apt_pin_name(require_str(params, "name")?, "name")?;
727 Ok(apt_preferences::remove_apt_pin(&name))
728 }
729
730 "GetLogrotateStatus" => {
732 let config = optional_validated(params, "config", validated_log_path)?;
733 Ok(logging::get_logrotate_status(config.as_deref()))
734 }
735 "ConfigureLogRotation" => {
736 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
737 let path = validated_log_path(require_str(params, "path")?, "path")?;
738 let frequency = require_str(params, "frequency")?;
739 if !matches!(frequency, "daily" | "weekly" | "monthly") {
740 return Err(ExecutorError::InvalidParam("frequency"));
741 }
742 let rotate = require_u32(params, "rotate")?;
743 if rotate > 1000 {
744 return Err(ExecutorError::InvalidParam("rotate"));
745 }
746 let compress = params
747 .get("compress")
748 .and_then(|v| v.as_bool())
749 .unwrap_or(false);
750 Ok(logging::configure_log_rotation(
751 &name, &path, frequency, rotate, compress,
752 ))
753 }
754 "RemoveLogRotation" => {
755 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
756 Ok(logging::remove_log_rotation(&name))
757 }
758 "ConfigureRemoteSyslog" => {
759 let host = validated_syslog_host(require_str(params, "host")?, "host")?;
760 let port = require_u32(params, "port")?;
761 if !(1..=65535).contains(&port) {
762 return Err(ExecutorError::InvalidParam("port"));
763 }
764 let protocol = require_str(params, "protocol")?;
765 if !matches!(protocol, "tcp" | "udp") {
766 return Err(ExecutorError::InvalidParam("protocol"));
767 }
768 Ok(logging::configure_remote_syslog(
769 &host,
770 port as u16,
771 protocol,
772 ))
773 }
774 "RemoveRemoteSyslog" => Ok(logging::remove_remote_syslog()),
775
776 "GetPasswordAging" => {
778 let user = validated_username(require_str(params, "user")?, "user")?;
779 Ok(pam::get_password_aging(&user))
780 }
781 "SetPasswordAging" => {
782 let user = validated_username(require_str(params, "user")?, "user")?;
783 let mut flags = Vec::new();
785 for (key, opt) in [("max_days", "-M"), ("min_days", "-m"), ("warn_days", "-W")] {
786 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
787 if n > 99999 {
788 return Err(ExecutorError::InvalidParam(key));
789 }
790 flags.push(opt.to_string());
791 flags.push(n.to_string());
792 }
793 }
794 if flags.is_empty() {
795 return Err(ExecutorError::MissingParam("max_days"));
796 }
797 Ok(pam::set_password_aging(&user, &flags))
798 }
799 "SetPasswordPolicy" => {
800 let mut extra = Vec::new();
803 if let Some(n) = params.get("minlen").and_then(|v| v.as_u64()) {
804 if !(1..=128).contains(&n) {
805 return Err(ExecutorError::InvalidParam("minlen"));
806 }
807 extra.push("--minlen".to_string());
808 extra.push(n.to_string());
809 }
810 for (key, flag) in [
811 ("dcredit", "--dcredit"),
812 ("ucredit", "--ucredit"),
813 ("lcredit", "--lcredit"),
814 ("ocredit", "--ocredit"),
815 ] {
816 if let Some(n) = params.get(key).and_then(|v| v.as_i64()) {
817 if !(-64..=64).contains(&n) {
818 return Err(ExecutorError::InvalidParam(key));
819 }
820 extra.push(flag.to_string());
821 extra.push(n.to_string());
822 }
823 }
824 if extra.is_empty() {
825 return Err(ExecutorError::MissingParam("minlen"));
826 }
827 Ok(pam::set_password_policy(&extra))
828 }
829 "SetAccountLockout" => {
830 let mut extra = Vec::new();
832 if let Some(n) = params.get("deny").and_then(|v| v.as_u64()) {
833 if !(1..=1000).contains(&n) {
834 return Err(ExecutorError::InvalidParam("deny"));
835 }
836 extra.push("--deny".to_string());
837 extra.push(n.to_string());
838 }
839 for (key, flag) in [
840 ("unlock_time", "--unlock-time"),
841 ("fail_interval", "--fail-interval"),
842 ] {
843 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
844 if n > 604800 {
845 return Err(ExecutorError::InvalidParam(key));
846 }
847 extra.push(flag.to_string());
848 extra.push(n.to_string());
849 }
850 }
851 if extra.is_empty() {
852 return Err(ExecutorError::MissingParam("deny"));
853 }
854 Ok(pam::set_account_lockout(&extra))
855 }
856
857 "GetMemoryInfo" => Ok(system_info::get_memory_info_spec()),
859
860 "GetFirewallState" => Ok(network::get_firewall_state()),
862 "GetNetworkStatus" => Ok(network::get_network_status()),
863 "GetListeningPorts" => Ok(network::get_listening_ports()),
864 "ConfigureWifi" => {
865 let ssid = validated_safe_arg(require_str(params, "ssid")?, "ssid")?;
866 let password = params
868 .get("password")
869 .and_then(|v| v.as_str())
870 .filter(|p| !p.is_empty())
871 .map(|p| validated_safe_arg(p, "password"))
872 .transpose()?;
873 Ok(network::configure_wifi(&ssid, password.as_deref()))
874 }
875 "SetDnsServers" => {
876 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
877 let servers = str_array_or_empty(params, "servers")?;
878 let validated: Vec<String> = servers
879 .iter()
880 .map(|s| validated_safe_arg(s, "servers"))
881 .collect::<Result<_, _>>()?;
882 let refs: Vec<&str> = validated.iter().map(String::as_str).collect();
883 Ok(network::set_dns_servers(&interface, &refs))
884 }
885 "ConfigureFirewall" => {
886 let zone = validated_safe_arg(require_str(params, "zone")?, "zone")?;
887 let service = validated_safe_arg(require_str(params, "service")?, "service")?;
888 Ok(network::configure_firewall(
889 &zone,
890 &service,
891 require_bool(params, "enabled")?,
892 ))
893 }
894
895 "ListUsers" => Ok(users::list_users()),
897 "ListGroups" => Ok(users::list_groups()),
898 "CreateUser" => {
899 let username = validated_username(resolve_username(params)?, "username")?;
900 let shell = params
901 .get("shell")
902 .and_then(|v| v.as_str())
903 .map(|s| validated_safe_arg(s, "shell"))
904 .transpose()?;
905 let home = params
906 .get("home")
907 .and_then(|v| v.as_str())
908 .map(|h| validated_safe_arg(h, "home"))
909 .transpose()?;
910 Ok(users::create_user(
911 &username,
912 shell.as_deref(),
913 home.as_deref(),
914 ))
915 }
916 "DeleteUser" => {
917 let username = validated_username(resolve_username(params)?, "username")?;
918 Ok(users::delete_user(&username))
919 }
920 "AddUserToGroup" => {
921 let username = validated_username(resolve_username(params)?, "username")?;
922 let group = validated_group(require_str(params, "group")?, "group")?;
923 Ok(users::add_user_to_group(&username, &group))
924 }
925 "RemoveUserFromGroup" => {
926 let username = validated_username(resolve_username(params)?, "username")?;
927 let group = validated_group(require_str(params, "group")?, "group")?;
928 Ok(users::remove_user_from_group(&username, &group))
929 }
930 "CreateGroup" => {
931 let group = validated_group(require_str(params, "group")?, "group")?;
932 let system = params
933 .get("system")
934 .and_then(|v| v.as_bool())
935 .unwrap_or(false);
936 Ok(users::create_group(&group, system))
937 }
938 "DeleteGroup" => {
939 let group = validated_group(require_str(params, "group")?, "group")?;
940 Ok(users::delete_group(&group))
941 }
942 "LockUserAccount" => {
943 let username = validated_username(resolve_username(params)?, "username")?;
944 Ok(users::lock_user_account(&username))
945 }
946 "UnlockUserAccount" => {
947 let username = validated_username(resolve_username(params)?, "username")?;
948 Ok(users::unlock_user_account(&username))
949 }
950
951 "GetAuthorizedKeys" => {
953 let username = validated_username(resolve_username(params)?, "username")?;
954 Ok(ssh::get_authorized_keys(&username))
955 }
956 "AddAuthorizedKey" => {
957 let username = validated_username(resolve_username(params)?, "username")?;
958 let public_key = validated_public_key(require_str(params, "public_key")?)?;
959 Ok(ssh::add_authorized_key(&username, &public_key))
960 }
961 "RemoveAuthorizedKey" => {
962 let username = validated_username(resolve_username(params)?, "username")?;
963 let public_key = validated_public_key(require_str(params, "public_key")?)?;
964 Ok(ssh::remove_authorized_key(&username, &public_key))
965 }
966 "SetSshdOption" => {
967 let option = require_str(params, "option")?;
971 let value = require_str(params, "value")?;
972 let allowed_values: &[&str] = match option {
973 "PermitRootLogin" => &["yes", "no", "prohibit-password", "forced-commands-only"],
974 "PasswordAuthentication"
975 | "PubkeyAuthentication"
976 | "X11Forwarding"
977 | "PermitEmptyPasswords" => &["yes", "no"],
978 _ => return Err(ExecutorError::InvalidParam("option")),
979 };
980 if !allowed_values.contains(&value) {
981 return Err(ExecutorError::InvalidParam("value"));
982 }
983 Ok(ssh::set_sshd_option(option, value))
984 }
985
986 "AptUpdate" => Ok(apt::apt_update()),
988 "AptUpgrade" => Ok(apt::apt_upgrade()),
989 "AptInstall" => {
990 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
991 Ok(apt::apt_install(&package))
992 }
993 "AptRemove" => {
994 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
995 Ok(apt::apt_remove(&package))
996 }
997 "AptPurge" => {
998 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
999 Ok(apt::apt_purge(&package))
1000 }
1001 "AptAutoremove" => Ok(apt::apt_autoremove()),
1002 "AptHold" => {
1003 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1004 Ok(apt::apt_hold(&package))
1005 }
1006 "AptUnhold" => {
1007 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1008 Ok(apt::apt_unhold(&package))
1009 }
1010 "AptSearch" => {
1011 let term = validated_safe_arg(require_str(params, "term")?, "term")?;
1012 Ok(apt::apt_search(&term))
1013 }
1014 "AptListInstalled" => Ok(apt::apt_list_installed()),
1015 "AptShow" => {
1016 let package = validated_safe_arg(require_str(params, "package")?, "package")?;
1017 Ok(apt::apt_show(&package))
1018 }
1019 "AptListUpgradable" => Ok(apt::apt_list_upgradable()),
1020 "AptHistoryList" => Ok(apt::apt_history_list()),
1021 "ConfigureUnattendedUpgrades" => Ok(apt::configure_unattended_upgrades(require_bool(
1022 params, "enabled",
1023 )?)),
1024
1025 "AddPpa" => {
1027 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1028 Ok(ppa::add_ppa(&name))
1029 }
1030 "RemovePpa" => {
1031 let name = validated_ppa_name(require_str(params, "name")?, "name")?;
1032 Ok(ppa::remove_ppa(&name))
1033 }
1034
1035 "SnapInstall" => {
1037 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1038 let channel = params
1039 .get("channel")
1040 .and_then(|v| v.as_str())
1041 .filter(|s| !s.is_empty())
1042 .map(|s| validated_safe_arg(s, "channel"))
1043 .transpose()?;
1044 let auto_update = params
1045 .get("auto_update")
1046 .and_then(|v| v.as_bool())
1047 .unwrap_or(false);
1048 Ok(snap::snap_install(&name, channel.as_deref(), auto_update))
1049 }
1050 "SnapRemove" => {
1051 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1052 Ok(snap::snap_remove(&name))
1053 }
1054 "SnapRefresh" => {
1055 let name = params
1056 .get("name")
1057 .and_then(|v| v.as_str())
1058 .filter(|s| !s.is_empty())
1059 .map(|s| validated_safe_arg(s, "name"))
1060 .transpose()?;
1061 Ok(snap::snap_refresh(name.as_deref()))
1062 }
1063 "SnapHold" => {
1064 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1065 Ok(snap::snap_hold(&name))
1066 }
1067 "SnapUnhold" => {
1068 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1069 Ok(snap::snap_unhold(&name))
1070 }
1071 "SnapList" => Ok(snap::snap_list()),
1072 "SnapInfo" => {
1073 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1074 Ok(snap::snap_info(&name))
1075 }
1076 "SnapRevert" => {
1077 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1078 Ok(snap::snap_revert(&name))
1079 }
1080 "SnapClassicInstall" => {
1081 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1082 Ok(snap::snap_classic_install(&name))
1083 }
1084
1085 "GrubGetKargs" => Ok(grub::grub_get_kargs()),
1087 "GrubSetKargs" => {
1088 let append: Vec<String> = params
1089 .get("append")
1090 .and_then(|v| v.as_array())
1091 .map(|arr| {
1092 arr.iter()
1093 .filter_map(|v| v.as_str())
1094 .map(String::from)
1095 .collect()
1096 })
1097 .unwrap_or_default();
1098 let delete: Vec<String> = params
1099 .get("delete")
1100 .and_then(|v| v.as_array())
1101 .map(|arr| {
1102 arr.iter()
1103 .filter_map(|v| v.as_str())
1104 .map(String::from)
1105 .collect()
1106 })
1107 .unwrap_or_default();
1108 for a in &append {
1117 validated_safe_arg(a, "append")?;
1118 validated_safe_kernel_arg(a, "append")?;
1119 }
1120 for d in &delete {
1121 validated_safe_arg(d, "delete")?;
1122 }
1123 let append_refs: Vec<&str> = append.iter().map(String::as_str).collect();
1126 let delete_refs: Vec<&str> = delete.iter().map(String::as_str).collect();
1127 grub::grub_set_kargs(&append_refs, &delete_refs)
1128 .map_err(|_| ExecutorError::MissingParam("append or delete"))
1129 }
1130
1131 "CheckPendingReboot" => Ok(reboot::check_pending_reboot()),
1133
1134 "UfwEnable" => Ok(ufw::ufw_enable()),
1136 "UfwDisable" => Ok(ufw::ufw_disable()),
1137 "UfwAllow" => {
1138 let port_or_service = validated_port_or_service(
1139 require_str(params, "port_or_service")?,
1140 "port_or_service",
1141 )?;
1142 Ok(ufw::ufw_allow(&port_or_service))
1143 }
1144 "UfwDeny" => {
1145 let port_or_service = validated_port_or_service(
1146 require_str(params, "port_or_service")?,
1147 "port_or_service",
1148 )?;
1149 Ok(ufw::ufw_deny(&port_or_service))
1150 }
1151 "UfwReset" => Ok(ufw::ufw_reset()),
1152 "UfwStatus" => Ok(ufw::ufw_status()),
1153
1154 "DistroboxList" => Ok(distrobox::distrobox_list()),
1156 "DistroboxCreate" => {
1157 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1158 let image = validated_safe_arg(require_str(params, "image")?, "image")?;
1159 Ok(distrobox::distrobox_create(&name, &image))
1160 }
1161 "DistroboxRemove" => {
1162 let name = validated_safe_arg(require_str(params, "name")?, "name")?;
1163 Ok(distrobox::distrobox_remove(&name))
1164 }
1165
1166 "NetplanGetConfig" => Ok(netplan::netplan_get_config()),
1168 "NetplanApply" => Ok(netplan::netplan_apply()),
1169 "NetplanSet" => {
1170 let key = validated_safe_arg(require_str(params, "key")?, "key")?;
1171 let value = validated_safe_arg(require_str(params, "value")?, "value")?;
1172 Ok(netplan::netplan_set(&key, &value))
1173 }
1174 "NetplanGenerate" => Ok(netplan::netplan_generate()),
1175
1176 "UfwDeleteRule" => {
1178 let rule_number = require_positive_u32(params, "rule_number")?;
1179 ufw::ufw_delete_rule(rule_number)
1180 .map_err(|_| ExecutorError::InvalidParam("rule_number"))
1181 }
1182 "UfwLimit" => {
1183 let target = validated_port_or_service(require_str(params, "target")?, "target")?;
1184 Ok(ufw::ufw_limit(&target))
1185 }
1186
1187 "ProStatus" => Ok(ubuntu_pro::pro_status()),
1189 "ProAttach" => {
1190 let token = require_str(params, "token")?;
1192 let token = validated_safe_arg(token, "token")?;
1194 Ok(ubuntu_pro::pro_attach(&token))
1195 }
1196 "ProDetach" => Ok(ubuntu_pro::pro_detach()),
1197 "EnableProService" => {
1198 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1199 Ok(ubuntu_pro::enable_pro_service(&service))
1200 }
1201 "DisableProService" => {
1202 let service = validated_pro_service(require_str(params, "service")?, "service")?;
1203 Ok(ubuntu_pro::disable_pro_service(&service))
1204 }
1205
1206 "LivepatchStatus" => Ok(livepatch::livepatch_status()),
1208
1209 "MultipassList" => Ok(multipass::multipass_list()),
1211
1212 "UbuntuReleaseUpgrade" => Ok(release_upgrade::ubuntu_release_upgrade()),
1214
1215 "ResolvectlStatus" => Ok(resolvectl::resolvectl_status()),
1217 "ResolvectlSetDns" => {
1218 let interface = validated_safe_arg(require_str(params, "interface")?, "interface")?;
1219 let raw_servers: Vec<String> = params
1220 .get("servers")
1221 .and_then(|v| v.as_array())
1222 .map(|arr| {
1223 arr.iter()
1224 .filter_map(|v| v.as_str())
1225 .map(String::from)
1226 .collect()
1227 })
1228 .unwrap_or_default();
1229 if raw_servers.is_empty() {
1230 return Err(ExecutorError::MissingParam("servers"));
1231 }
1232 let mut parsed_servers: Vec<IpAddr> = Vec::with_capacity(raw_servers.len());
1236 for s in &raw_servers {
1237 let addr = IpAddr::from_str(s).map_err(|_| ExecutorError::InvalidIpAddress {
1238 param: "servers",
1239 value: s.clone(),
1240 })?;
1241 parsed_servers.push(addr);
1242 }
1243 Ok(resolvectl::resolvectl_set_dns(&interface, &parsed_servers))
1244 }
1245
1246 "AppArmorStatus" => Ok(apparmor::apparmor_status()),
1248 "AppArmorEnforce" => {
1249 let profile_path =
1250 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1251 Ok(apparmor::apparmor_enforce(&profile_path))
1252 }
1253 "AppArmorComplain" => {
1254 let profile_path =
1255 validated_apparmor_profile(require_str(params, "profile_path")?, "profile_path")?;
1256 Ok(apparmor::apparmor_complain(&profile_path))
1257 }
1258
1259 "CloudInitStatus" => Ok(cloudinit::cloud_init_status()),
1261
1262 "UbuntuInstallFlatpak" => {
1264 let username = validated_username(resolve_username(params)?, "username")?;
1265 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1266 let remote = validated_safe_arg(require_str(params, "remote")?, "remote")?;
1267 Ok(flatpak::ubuntu_install_flatpak(&username, &app_id, &remote))
1268 }
1269 "UbuntuRemoveFlatpak" => {
1270 let username = validated_username(resolve_username(params)?, "username")?;
1271 let app_id = validated_safe_arg(require_str(params, "app_id")?, "app_id")?;
1272 Ok(flatpak::ubuntu_remove_flatpak(&username, &app_id))
1273 }
1274 "UbuntuUpdateFlatpak" => {
1275 let username = validated_username(resolve_username(params)?, "username")?;
1276 let app_id = params
1277 .get("app_id")
1278 .and_then(|v| v.as_str())
1279 .filter(|s| !s.is_empty())
1280 .map(|s| validated_safe_arg(s, "app_id"))
1281 .transpose()?;
1282 Ok(flatpak::ubuntu_update_flatpak(&username, app_id.as_deref()))
1283 }
1284 "UbuntuListFlatpaks" => {
1285 let username = validated_username(resolve_username(params)?, "username")?;
1286 Ok(flatpak::ubuntu_list_flatpaks(&username))
1287 }
1288
1289 "Fail2banStatus" => {
1291 let jail = params
1292 .get("jail")
1293 .and_then(|v| v.as_str())
1294 .filter(|s| !s.is_empty())
1295 .map(|s| validated_safe_arg(s, "jail"))
1296 .transpose()?;
1297 Ok(fail2ban::fail2ban_status(jail.as_deref()))
1298 }
1299 "Fail2banBanIp" => {
1300 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1301 let ip = require_str(params, "ip")?;
1302 fail2ban::fail2ban_ban_ip(&jail, ip).map_err(|e| match e {
1303 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1304 param: "ip",
1305 value: v,
1306 },
1307 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1308 })
1309 }
1310 "Fail2banUnbanIp" => {
1311 let jail = validated_safe_arg(require_str(params, "jail")?, "jail")?;
1312 let ip = require_str(params, "ip")?;
1313 fail2ban::fail2ban_unban_ip(&jail, ip).map_err(|e| match e {
1314 fail2ban::Fail2banError::InvalidIpAddress(v) => ExecutorError::InvalidIpAddress {
1315 param: "ip",
1316 value: v,
1317 },
1318 fail2ban::Fail2banError::InvalidJail(_) => ExecutorError::InvalidParam("jail"),
1319 })
1320 }
1321 "ConfigureFail2banJail" => {
1322 let name = validated_sudoers_name(require_str(params, "name")?, "name")?;
1323 let mut extra = Vec::new();
1324 if let Some(n) = params.get("enabled").and_then(|v| v.as_bool()) {
1325 extra.push("--enabled".to_string());
1326 extra.push(if n { "true" } else { "false" }.to_string());
1327 }
1328 if let Some(n) = params.get("maxretry").and_then(|v| v.as_u64()) {
1329 if !(1..=100).contains(&n) {
1330 return Err(ExecutorError::InvalidParam("maxretry"));
1331 }
1332 extra.push("--maxretry".to_string());
1333 extra.push(n.to_string());
1334 }
1335 for (key, flag) in [("bantime", "--bantime"), ("findtime", "--findtime")] {
1336 if let Some(n) = params.get(key).and_then(|v| v.as_u64()) {
1337 if n > 2_592_000 {
1338 return Err(ExecutorError::InvalidParam(key));
1339 }
1340 extra.push(flag.to_string());
1341 extra.push(n.to_string());
1342 }
1343 }
1344 if extra.is_empty() {
1345 return Err(ExecutorError::MissingParam("maxretry"));
1346 }
1347 Ok(fail2ban::configure_fail2ban_jail(&name, &extra))
1348 }
1349
1350 "GetAuditRules" => Ok(auditd::get_audit_rules()),
1352 "AddAuditRule" => {
1353 let path = validated_audit_path(require_str(params, "path")?, "path")?;
1354 let perms = validated_audit_perms(require_str(params, "perms")?, "perms")?;
1355 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1356 Ok(auditd::add_audit_rule(&path, &perms, &key))
1357 }
1358 "RemoveAuditRule" => {
1359 let key = validated_sudoers_name(require_str(params, "key")?, "key")?;
1360 Ok(auditd::remove_audit_rule(&key))
1361 }
1362
1363 "GetCertificates" => Ok(certbot::get_certificates()),
1365 "ObtainCertificate" => {
1366 let mut domains = Vec::new();
1368 if let Some(arr) = params.get("domains").and_then(|v| v.as_array()) {
1369 for d in arr {
1370 let s = d.as_str().ok_or(ExecutorError::InvalidParam("domains"))?;
1371 domains.push(validated_domain(s, "domains")?);
1372 }
1373 } else if let Some(d) = params.get("domain").and_then(|v| v.as_str()) {
1374 domains.push(validated_domain(d, "domain")?);
1375 }
1376 if domains.is_empty() {
1377 return Err(ExecutorError::MissingParam("domains"));
1378 }
1379 let email = validated_email(require_str(params, "email")?, "email")?;
1380 let challenge = params
1381 .get("challenge")
1382 .and_then(|v| v.as_str())
1383 .unwrap_or("standalone");
1384 if !matches!(challenge, "standalone" | "nginx" | "apache") {
1385 return Err(ExecutorError::InvalidParam("challenge"));
1386 }
1387 Ok(certbot::obtain_certificate(&domains, &email, challenge))
1388 }
1389 "RenewCertificates" => Ok(certbot::renew_certificates()),
1390
1391 _ => Err(ExecutorError::UnknownAction(action_name.to_string())),
1392 }
1393}
1394
1395pub async fn execute_spec(spec: &ActionSpec) -> Result<ExecutionOutput, ExecutorError> {
1401 match &spec.mechanism {
1402 ActionMechanism::Command { program, args } => {
1403 let output = tokio::process::Command::new(program)
1404 .args(args)
1405 .output()
1406 .await?;
1407 Ok(ExecutionOutput {
1408 stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
1409 stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1410 exit_code: output.status.code().unwrap_or(-1),
1411 })
1412 }
1413 ActionMechanism::FileScan { path } => {
1414 let mut entries = tokio::fs::read_dir(path).await?;
1415 let mut names = Vec::new();
1416 while let Some(entry) = entries.next_entry().await? {
1417 names.push(entry.file_name().to_string_lossy().into_owned());
1418 }
1419 names.sort();
1420 Ok(ExecutionOutput {
1421 stdout: names.join("\n"),
1422 stderr: String::new(),
1423 exit_code: 0,
1424 })
1425 }
1426 ActionMechanism::FileWrite { path, content } => {
1427 if let Some(parent) = std::path::Path::new(path).parent() {
1428 tokio::fs::create_dir_all(parent).await?;
1429 }
1430 tokio::fs::write(path, content).await?;
1431 Ok(ExecutionOutput {
1432 stdout: String::new(),
1433 stderr: String::new(),
1434 exit_code: 0,
1435 })
1436 }
1437 ActionMechanism::FilePatch {
1438 path,
1439 search,
1440 replace,
1441 } => {
1442 let content = tokio::fs::read_to_string(path).await?;
1443 let patched = content.replacen(search.as_str(), replace.as_str(), 1);
1444 if patched == content && !search.is_empty() {
1445 return Ok(ExecutionOutput {
1446 stdout: String::new(),
1447 stderr: format!("search string not found in file: {}", path),
1448 exit_code: 1,
1449 });
1450 }
1451 tokio::fs::write(path, patched).await?;
1452 Ok(ExecutionOutput {
1453 stdout: String::new(),
1454 stderr: String::new(),
1455 exit_code: 0,
1456 })
1457 }
1458 ActionMechanism::FileDelete { path } => {
1459 tokio::fs::remove_file(path).await?;
1460 Ok(ExecutionOutput {
1461 stdout: String::new(),
1462 stderr: String::new(),
1463 exit_code: 0,
1464 })
1465 }
1466 }
1467}
1468
1469fn require_str<'a>(params: &'a Value, key: &'static str) -> Result<&'a str, ExecutorError> {
1470 match params.get(key) {
1471 None => Err(ExecutorError::MissingParam(key)),
1472 Some(v) => v.as_str().ok_or(ExecutorError::InvalidParam(key)),
1473 }
1474}
1475
1476fn optional_validated<F>(
1480 params: &Value,
1481 key: &'static str,
1482 validator: F,
1483) -> Result<Option<String>, ExecutorError>
1484where
1485 F: FnOnce(&str, &'static str) -> Result<String, ExecutorError>,
1486{
1487 match params
1488 .get(key)
1489 .and_then(|v| v.as_str())
1490 .filter(|s| !s.is_empty())
1491 {
1492 Some(s) => Ok(Some(validator(s, key)?)),
1493 None => Ok(None),
1494 }
1495}
1496
1497fn resolve_username(params: &Value) -> Result<&str, ExecutorError> {
1507 params
1508 .get("username")
1509 .or_else(|| params.get("user"))
1510 .ok_or(ExecutorError::MissingParam("username"))
1511 .and_then(|v| v.as_str().ok_or(ExecutorError::InvalidParam("username")))
1512}
1513
1514fn validated_repo_id(params: &Value) -> Result<&str, ExecutorError> {
1518 let id = require_str(params, "repo_id")?;
1519 let valid = !id.is_empty()
1520 && id
1521 .chars()
1522 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_');
1523 if valid {
1524 Ok(id)
1525 } else {
1526 Err(ExecutorError::InvalidParam("repo_id"))
1527 }
1528}
1529
1530fn validated_no_newline<'a>(
1533 params: &'a Value,
1534 key: &'static str,
1535) -> Result<&'a str, ExecutorError> {
1536 let val = require_str(params, key)?;
1537 if val.contains('\n') || val.contains('\r') {
1538 Err(ExecutorError::InvalidParam(key))
1539 } else {
1540 Ok(val)
1541 }
1542}
1543
1544fn validated_public_key(s: &str) -> Result<String, ExecutorError> {
1548 const MAX_LEN: usize = 8192;
1549 const ALLOWED_PREFIXES: &[&str] = &[
1550 "ssh-rsa",
1551 "ssh-ed25519",
1552 "ssh-ed25519-sk",
1553 "ecdsa-sha2-nistp256",
1554 "ecdsa-sha2-nistp384",
1555 "ecdsa-sha2-nistp521",
1556 "sk-ssh-ed25519",
1557 "sk-ecdsa-sha2-nistp256",
1558 ];
1559
1560 if s.is_empty() || s.len() > MAX_LEN {
1561 return Err(ExecutorError::InvalidParam("public_key"));
1562 }
1563 if !ALLOWED_PREFIXES.iter().any(|p| s.starts_with(p)) {
1564 return Err(ExecutorError::InvalidParam("public_key"));
1565 }
1566 if s.chars().any(|c| {
1580 matches!(c, '\n' | '\r' | '\'' | '|' | ';' | '`' | '$' | '\\' | '&')
1581 || !c.is_ascii()
1582 || c.is_ascii_control()
1583 }) {
1584 return Err(ExecutorError::InvalidParam("public_key"));
1585 }
1586 Ok(s.to_string())
1587}
1588
1589fn require_bool(params: &Value, key: &'static str) -> Result<bool, ExecutorError> {
1590 match params.get(key) {
1591 None => Err(ExecutorError::MissingParam(key)),
1592 Some(v) => v.as_bool().ok_or(ExecutorError::InvalidParam(key)),
1593 }
1594}
1595
1596fn require_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1597 match params.get(key) {
1598 None => Err(ExecutorError::MissingParam(key)),
1599 Some(v) => {
1600 let n = v.as_u64().ok_or(ExecutorError::InvalidParam(key))?;
1601 u32::try_from(n).map_err(|_| ExecutorError::InvalidParam(key))
1602 }
1603 }
1604}
1605
1606fn require_positive_u32(params: &Value, key: &'static str) -> Result<u32, ExecutorError> {
1610 let n = require_u32(params, key)?;
1611 if n == 0 {
1612 return Err(ExecutorError::InvalidParam(key));
1613 }
1614 Ok(n)
1615}
1616
1617fn str_array_or_empty(params: &Value, key: &'static str) -> Result<Vec<String>, ExecutorError> {
1621 match params.get(key) {
1622 None | Some(Value::Null) => Ok(vec![]),
1623 Some(Value::Array(arr)) => arr
1624 .iter()
1625 .map(|v| {
1626 v.as_str()
1627 .map(String::from)
1628 .ok_or(ExecutorError::InvalidParam(key))
1629 })
1630 .collect(),
1631 _ => Err(ExecutorError::InvalidParam(key)),
1632 }
1633}
1634
1635fn validated_safe_kernel_arg(arg: &str, param: &'static str) -> Result<(), ExecutorError> {
1661 const BLOCKED_PREFIXES: &[&str] = &[
1662 "init=",
1663 "selinux=0",
1664 "enforcing=0",
1665 "security=",
1666 "module_blacklist=",
1667 ];
1668 const BLOCKED_EXACT: &[&str] = &["single", "s", "1"];
1669 const BLOCKED_UNIT_PREFIXES: &[&str] = &["emergency", "rescue", "single"];
1670
1671 let lower = arg.to_lowercase();
1672 let base = lower.split('=').next().unwrap_or(&lower);
1674
1675 if BLOCKED_PREFIXES.iter().any(|p| lower.starts_with(p)) {
1676 return Err(ExecutorError::InvalidParam(param));
1677 }
1678 if BLOCKED_EXACT.iter().any(|e| lower == *e) {
1679 return Err(ExecutorError::InvalidParam(param));
1680 }
1681 if let Some(unit_val) = lower.strip_prefix("systemd.unit=") {
1683 if BLOCKED_UNIT_PREFIXES
1684 .iter()
1685 .any(|u| unit_val.starts_with(u))
1686 {
1687 return Err(ExecutorError::InvalidParam(param));
1688 }
1689 }
1690 if BLOCKED_EXACT.contains(&base) {
1692 return Err(ExecutorError::InvalidParam(param));
1693 }
1694 Ok(())
1695}
1696
1697fn validated_kill_signal(s: &str) -> Result<&'static str, ExecutorError> {
1705 let normalized = s.trim().to_ascii_uppercase();
1706 match normalized.strip_prefix("SIG").unwrap_or(&normalized) {
1707 "TERM" => Ok("TERM"),
1708 "KILL" => Ok("KILL"),
1709 "HUP" => Ok("HUP"),
1710 "INT" => Ok("INT"),
1711 _ => Err(ExecutorError::InvalidParam("signal")),
1712 }
1713}
1714
1715pub fn rollback_spec_for(action_name: &str) -> Option<ActionSpec> {
1725 match action_name {
1726 "UpdateSystem"
1727 | "InstallPackages"
1728 | "RemovePackages"
1729 | "RebaseSystem"
1730 | "SetKernelArguments"
1731 | "AddLayeredPackage"
1732 | "RemoveLayeredPackage"
1733 | "ReplaceLayeredPackage"
1734 | "ResetLayeredPackageOverride"
1735 | "RemoveBasePackage" => Some(ActionSpec {
1736 action_name: "RollbackDeployment",
1737 mechanism: ActionMechanism::Command {
1738 program: "rpm-ostree",
1739 args: vec!["rollback".to_string()],
1740 },
1741 risk_level: sysknife_types::RiskLevel::High,
1742 reboot_required: true,
1743 rollback_available: false,
1744 }),
1745 _ => None,
1746 }
1747}
1748
1749#[cfg(test)]
1750mod tests {
1751 use super::*;
1752 use serde_json::json;
1753 use sysknife_types::RiskLevel;
1754 use tempfile::tempdir;
1755
1756 #[test]
1759 fn build_spec_no_params_for_get_system_state() {
1760 let spec = build_action_spec("GetSystemState", &json!({})).unwrap();
1761 assert_eq!(spec.action_name, "GetSystemState");
1762 assert_eq!(spec.risk_level, RiskLevel::Low);
1763 }
1764
1765 #[test]
1766 fn build_spec_get_datetime_is_low_risk() {
1767 let spec = build_action_spec("GetDateTime", &json!({})).unwrap();
1768 assert_eq!(spec.action_name, "GetDateTime");
1769 assert_eq!(spec.risk_level, RiskLevel::Low);
1770 assert!(!spec.reboot_required);
1771 }
1772
1773 #[test]
1774 fn build_spec_unknown_action_returns_error() {
1775 let err = build_action_spec("NonExistent", &json!({})).unwrap_err();
1776 assert!(
1777 matches!(&err, ExecutorError::UnknownAction(n) if n == "NonExistent"),
1778 "expected UnknownAction, got: {err}"
1779 );
1780 }
1781
1782 #[test]
1783 fn build_spec_missing_param_for_install_flatpak() {
1784 let err = build_action_spec("InstallFlatpak", &json!({})).unwrap_err();
1786 assert!(
1787 matches!(err, ExecutorError::MissingParam("username")),
1788 "expected MissingParam(username), got: {err}"
1789 );
1790 }
1791
1792 #[test]
1796 fn build_spec_flatpak_accepts_user_alias() {
1797 let spec = build_action_spec("ListInstalledFlatpaks", &json!({ "user": "alice" })).unwrap();
1798 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
1799 }
1800
1801 #[test]
1803 fn build_spec_resolve_username_prefers_explicit_username() {
1804 let spec = build_action_spec(
1805 "ListInstalledFlatpaks",
1806 &json!({ "username": "alice", "user": "bob" }),
1807 )
1808 .unwrap();
1809 assert_eq!(spec.action_name, "ListInstalledFlatpaks");
1811 }
1812
1813 #[test]
1816 fn build_spec_install_flatpak_defaults_remote_to_flathub() {
1817 let spec = build_action_spec(
1818 "InstallFlatpak",
1819 &json!({ "username": "alice", "app_id": "org.mozilla.firefox" }),
1820 )
1821 .unwrap();
1822 assert_eq!(
1823 spec.mechanism,
1824 ActionMechanism::Command {
1825 program: "sudo",
1826 args: vec![
1827 "runuser".to_string(),
1828 "-u".to_string(),
1829 "alice".to_string(),
1830 "--".to_string(),
1831 "flatpak".to_string(),
1832 "install".to_string(),
1833 "--user".to_string(),
1834 "-y".to_string(),
1835 "flathub".to_string(),
1836 "org.mozilla.firefox".to_string(),
1837 ],
1838 }
1839 );
1840 }
1841
1842 #[test]
1843 fn build_spec_install_flatpak_injects_app_and_remote() {
1844 let spec = build_action_spec(
1845 "InstallFlatpak",
1846 &json!({
1847 "username": "alice",
1848 "app_id": "org.mozilla.firefox",
1849 "remote": "flathub"
1850 }),
1851 )
1852 .unwrap();
1853 assert_eq!(spec.action_name, "InstallFlatpak");
1854 assert_eq!(
1855 spec.mechanism,
1856 ActionMechanism::Command {
1857 program: "sudo",
1858 args: vec![
1859 "runuser".to_string(),
1860 "-u".to_string(),
1861 "alice".to_string(),
1862 "--".to_string(),
1863 "flatpak".to_string(),
1864 "install".to_string(),
1865 "--user".to_string(),
1866 "-y".to_string(),
1867 "flathub".to_string(),
1868 "org.mozilla.firefox".to_string(),
1869 ],
1870 }
1871 );
1872 }
1873
1874 #[test]
1875 fn build_spec_pin_deployment_injects_index() {
1876 let spec = build_action_spec("PinDeployment", &json!({ "index": 1 })).unwrap();
1877 assert_eq!(
1878 spec.mechanism,
1879 ActionMechanism::Command {
1880 program: "sudo",
1881 args: vec![
1882 "ostree".to_string(),
1883 "admin".to_string(),
1884 "pin".to_string(),
1885 "1".to_string()
1886 ],
1887 }
1888 );
1889 }
1890
1891 #[test]
1892 fn build_spec_unpin_deployment_includes_unpin_flag() {
1893 let spec = build_action_spec("UnpinDeployment", &json!({ "index": 2 })).unwrap();
1894 assert_eq!(
1895 spec.mechanism,
1896 ActionMechanism::Command {
1897 program: "sudo",
1898 args: vec![
1899 "ostree".to_string(),
1900 "admin".to_string(),
1901 "pin".to_string(),
1902 "--unpin".to_string(),
1903 "2".to_string(),
1904 ],
1905 }
1906 );
1907 }
1908
1909 #[test]
1910 fn require_u32_rejects_overflow() {
1911 let err = build_action_spec("PinDeployment", &json!({ "index": u64::MAX })).unwrap_err();
1912 assert!(
1913 matches!(err, ExecutorError::InvalidParam("index")),
1914 "expected InvalidParam(index), got: {err}"
1915 );
1916 }
1917
1918 #[test]
1919 fn build_spec_rebase_system_injects_target_ref() {
1920 let spec = build_action_spec(
1921 "RebaseSystem",
1922 &json!({ "target_ref": "fedora/41/x86_64/silverblue" }),
1923 )
1924 .unwrap();
1925 assert_eq!(
1926 spec.mechanism,
1927 ActionMechanism::Command {
1928 program: "sudo",
1929 args: vec![
1930 "rpm-ostree".to_string(),
1931 "rebase".to_string(),
1932 "fedora/41/x86_64/silverblue".to_string(),
1933 ],
1934 }
1935 );
1936 }
1937
1938 #[test]
1939 fn build_spec_set_kernel_arguments_appends_and_deletes() {
1940 let spec = build_action_spec(
1941 "SetKernelArguments",
1942 &json!({ "add": ["mitigations=off"], "remove": ["quiet"] }),
1943 )
1944 .unwrap();
1945 assert_eq!(
1946 spec.mechanism,
1947 ActionMechanism::Command {
1948 program: "sudo",
1949 args: vec![
1950 "rpm-ostree".to_string(),
1951 "kargs".to_string(),
1952 "--append=mitigations=off".to_string(),
1953 "--delete=quiet".to_string(),
1954 ],
1955 }
1956 );
1957 }
1958
1959 #[test]
1960 fn build_spec_set_kernel_arguments_with_empty_arrays() {
1961 let spec =
1962 build_action_spec("SetKernelArguments", &json!({ "add": [], "remove": [] })).unwrap();
1963 assert_eq!(
1964 spec.mechanism,
1965 ActionMechanism::Command {
1966 program: "sudo",
1967 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
1968 }
1969 );
1970 }
1971
1972 #[test]
1973 fn build_spec_set_kernel_arguments_defaults_when_keys_absent() {
1974 let spec = build_action_spec("SetKernelArguments", &json!({})).unwrap();
1975 assert_eq!(
1976 spec.mechanism,
1977 ActionMechanism::Command {
1978 program: "sudo",
1979 args: vec!["rpm-ostree".to_string(), "kargs".to_string()],
1980 }
1981 );
1982 }
1983
1984 #[test]
1987 fn build_spec_add_package_repository_rejects_path_traversal() {
1988 let err = build_action_spec(
1989 "AddPackageRepository",
1990 &json!({ "repo_id": "../cron.d/evil", "repo_url": "https://evil.example/repo" }),
1991 )
1992 .unwrap_err();
1993 assert!(
1994 matches!(err, ExecutorError::InvalidParam("repo_id")),
1995 "expected InvalidParam(repo_id), got: {err}"
1996 );
1997 }
1998
1999 #[test]
2000 fn build_spec_add_package_repository_rejects_newline_in_url() {
2001 let err = build_action_spec(
2002 "AddPackageRepository",
2003 &json!({ "repo_id": "myrepo", "repo_url": "https://ok.example/\nbaseurl=evil" }),
2004 )
2005 .unwrap_err();
2006 assert!(
2007 matches!(err, ExecutorError::InvalidParam("repo_url")),
2008 "expected InvalidParam(repo_url), got: {err}"
2009 );
2010 }
2011
2012 #[test]
2013 fn build_spec_add_package_repository_accepts_valid_repo_id() {
2014 let spec = build_action_spec(
2015 "AddPackageRepository",
2016 &json!({ "repo_id": "my-repo_123", "repo_url": "https://ok.example/repo" }),
2017 )
2018 .unwrap();
2019 assert_eq!(spec.action_name, "AddPackageRepository");
2020 }
2021
2022 #[test]
2023 fn build_spec_remove_package_repository_rejects_path_traversal() {
2024 let err = build_action_spec(
2025 "RemovePackageRepository",
2026 &json!({ "repo_id": "../../etc/passwd" }),
2027 )
2028 .unwrap_err();
2029 assert!(
2030 matches!(err, ExecutorError::InvalidParam("repo_id")),
2031 "expected InvalidParam(repo_id), got: {err}"
2032 );
2033 }
2034
2035 #[tokio::test]
2036 async fn execute_spec_command_captures_stdout() {
2037 let spec = ActionSpec {
2038 action_name: "GetSystemState",
2039 mechanism: ActionMechanism::Command {
2040 program: "echo",
2041 args: vec!["hello".to_string()],
2042 },
2043 risk_level: RiskLevel::Low,
2044 reboot_required: false,
2045 rollback_available: false,
2046 };
2047 let out = execute_spec(&spec).await.unwrap();
2048 assert_eq!(out.stdout.trim(), "hello");
2049 assert_eq!(out.exit_code, 0);
2050 }
2051
2052 #[tokio::test]
2058 async fn real_executor_streams_each_stdout_line() {
2059 let spec = ActionSpec {
2060 action_name: "GetSystemState",
2061 mechanism: ActionMechanism::Command {
2062 program: "printf",
2063 args: vec!["line-one\\nline-two\\nline-three\\n".to_string()],
2064 },
2065 risk_level: RiskLevel::Low,
2066 reboot_required: false,
2067 rollback_available: false,
2068 };
2069 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
2070 let out = RealActionExecutor
2071 .execute_with_progress(&spec, tx)
2072 .await
2073 .unwrap();
2074
2075 let mut lines = Vec::new();
2076 while let Ok(line) = rx.try_recv() {
2077 lines.push(line);
2078 }
2079 assert_eq!(lines, vec!["line-one", "line-two", "line-three"]);
2080 assert_eq!(out.exit_code, 0);
2081 assert!(out.stdout.contains("line-two"));
2082 }
2083
2084 #[tokio::test]
2085 async fn execute_spec_file_write_creates_file() {
2086 let dir = tempdir().unwrap();
2087 let path = dir.path().join("test.conf").to_string_lossy().into_owned();
2088 let spec = ActionSpec {
2089 action_name: "AddPackageRepository",
2090 mechanism: ActionMechanism::FileWrite {
2091 path: path.clone(),
2092 content: "[repo]\nbaseurl=https://example.test\n".to_string(),
2093 },
2094 risk_level: RiskLevel::Medium,
2095 reboot_required: false,
2096 rollback_available: true,
2097 };
2098 let out = execute_spec(&spec).await.unwrap();
2099 assert_eq!(out.exit_code, 0);
2100 assert_eq!(
2101 std::fs::read_to_string(&path).unwrap(),
2102 "[repo]\nbaseurl=https://example.test\n"
2103 );
2104 }
2105
2106 #[tokio::test]
2107 async fn execute_spec_file_patch_replaces_first_occurrence() {
2108 let dir = tempdir().unwrap();
2109 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2110 std::fs::write(&path, "[myrepo]\nenabled=0\n").unwrap();
2111 let spec = ActionSpec {
2112 action_name: "EnablePackageRepository",
2113 mechanism: ActionMechanism::FilePatch {
2114 path: path.clone(),
2115 search: "enabled=0".to_string(),
2116 replace: "enabled=1".to_string(),
2117 },
2118 risk_level: RiskLevel::Medium,
2119 reboot_required: false,
2120 rollback_available: true,
2121 };
2122 execute_spec(&spec).await.unwrap();
2123 assert_eq!(
2124 std::fs::read_to_string(&path).unwrap(),
2125 "[myrepo]\nenabled=1\n"
2126 );
2127 }
2128
2129 #[tokio::test]
2130 async fn execute_spec_file_patch_returns_error_when_search_not_found() {
2131 let dir = tempdir().unwrap();
2132 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2133 std::fs::write(&path, "[myrepo]\nenabled=1\n").unwrap();
2134 let spec = ActionSpec {
2135 action_name: "EnablePackageRepository",
2136 mechanism: ActionMechanism::FilePatch {
2137 path: path.clone(),
2138 search: "enabled=0".to_string(),
2139 replace: "enabled=1".to_string(),
2140 },
2141 risk_level: RiskLevel::Medium,
2142 reboot_required: false,
2143 rollback_available: true,
2144 };
2145 let out = execute_spec(&spec).await.unwrap();
2146 assert_eq!(out.exit_code, 1, "should fail when search string is absent");
2147 assert!(
2148 out.stderr.contains("search string not found in file"),
2149 "stderr should explain the failure: {}",
2150 out.stderr
2151 );
2152 assert_eq!(
2154 std::fs::read_to_string(&path).unwrap(),
2155 "[myrepo]\nenabled=1\n"
2156 );
2157 }
2158
2159 #[tokio::test]
2160 async fn execute_spec_file_patch_allows_empty_search_string() {
2161 let dir = tempdir().unwrap();
2164 let path = dir.path().join("file.txt").to_string_lossy().into_owned();
2165 std::fs::write(&path, "hello").unwrap();
2166 let spec = ActionSpec {
2167 action_name: "Test",
2168 mechanism: ActionMechanism::FilePatch {
2169 path: path.clone(),
2170 search: String::new(),
2171 replace: "prefix-".to_string(),
2172 },
2173 risk_level: RiskLevel::Low,
2174 reboot_required: false,
2175 rollback_available: false,
2176 };
2177 let out = execute_spec(&spec).await.unwrap();
2178 assert_eq!(out.exit_code, 0);
2179 assert_eq!(std::fs::read_to_string(&path).unwrap(), "prefix-hello");
2180 }
2181
2182 #[tokio::test]
2183 async fn execute_spec_file_delete_removes_file() {
2184 let dir = tempdir().unwrap();
2185 let path = dir.path().join("repo.conf").to_string_lossy().into_owned();
2186 std::fs::write(&path, "[myrepo]\n").unwrap();
2187 let spec = ActionSpec {
2188 action_name: "RemovePackageRepository",
2189 mechanism: ActionMechanism::FileDelete { path: path.clone() },
2190 risk_level: RiskLevel::Medium,
2191 reboot_required: false,
2192 rollback_available: true,
2193 };
2194 execute_spec(&spec).await.unwrap();
2195 assert!(!std::path::Path::new(&path).exists());
2196 }
2197
2198 #[tokio::test]
2199 async fn execute_spec_file_scan_lists_directory_entries() {
2200 let dir = tempdir().unwrap();
2201 std::fs::write(dir.path().join("a.repo"), "[a]\n").unwrap();
2202 std::fs::write(dir.path().join("b.repo"), "[b]\n").unwrap();
2203 let spec = ActionSpec {
2204 action_name: "ListPackageRepositories",
2205 mechanism: ActionMechanism::FileScan {
2206 path: dir.path().to_string_lossy().into_owned(),
2207 },
2208 risk_level: RiskLevel::Low,
2209 reboot_required: false,
2210 rollback_available: false,
2211 };
2212 let out = execute_spec(&spec).await.unwrap();
2213 assert!(
2214 out.stdout.contains("a.repo"),
2215 "expected a.repo in: {}",
2216 out.stdout
2217 );
2218 assert!(
2219 out.stdout.contains("b.repo"),
2220 "expected b.repo in: {}",
2221 out.stdout
2222 );
2223 assert_eq!(out.exit_code, 0);
2224 }
2225
2226 #[test]
2229 fn rollback_spec_for_update_system_is_rpm_ostree_rollback() {
2230 let spec = rollback_spec_for("UpdateSystem").unwrap();
2231 assert_eq!(spec.action_name, "RollbackDeployment");
2232 assert!(
2233 matches!(
2234 &spec.mechanism,
2235 ActionMechanism::Command { program: "rpm-ostree", args }
2236 if args == &["rollback".to_string()]
2237 ),
2238 "expected rpm-ostree rollback, got: {:?}",
2239 spec.mechanism
2240 );
2241 assert!(!spec.rollback_available, "rollback spec must not recurse");
2242 }
2243
2244 #[test]
2245 fn rollback_spec_for_install_packages_is_rpm_ostree_rollback() {
2246 assert!(rollback_spec_for("InstallPackages").is_some());
2247 }
2248
2249 #[test]
2250 fn rollback_spec_for_remove_packages_is_rpm_ostree_rollback() {
2251 assert!(rollback_spec_for("RemovePackages").is_some());
2252 }
2253
2254 #[test]
2255 fn rollback_spec_for_rebase_system_is_rpm_ostree_rollback() {
2256 assert!(rollback_spec_for("RebaseSystem").is_some());
2257 }
2258
2259 #[test]
2260 fn rollback_spec_for_set_kernel_arguments_is_rpm_ostree_rollback() {
2261 assert!(rollback_spec_for("SetKernelArguments").is_some());
2262 }
2263
2264 #[test]
2265 fn rollback_spec_for_read_only_action_returns_none() {
2266 assert!(rollback_spec_for("GetSystemState").is_none());
2267 assert!(rollback_spec_for("ListUsers").is_none());
2268 assert!(rollback_spec_for("GetFirewallState").is_none());
2269 }
2270
2271 #[test]
2272 fn rollback_spec_for_non_rollbackable_actions_return_none() {
2273 assert!(rollback_spec_for("AddUserToGroup").is_none());
2274 assert!(rollback_spec_for("DeleteUser").is_none());
2275 assert!(rollback_spec_for("CleanupDeployments").is_none());
2276 assert!(rollback_spec_for("RollbackDeployment").is_none());
2278 }
2279
2280 #[test]
2283 fn public_key_accepts_valid_ed25519() {
2284 let key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl user@host";
2285 assert!(validated_public_key(key).is_ok());
2286 }
2287
2288 #[test]
2289 fn public_key_accepts_valid_rsa() {
2290 let key = "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQCtest user@host";
2291 assert!(validated_public_key(key).is_ok());
2292 }
2293
2294 #[test]
2295 fn public_key_rejects_empty() {
2296 assert!(matches!(
2297 validated_public_key(""),
2298 Err(ExecutorError::InvalidParam("public_key"))
2299 ));
2300 }
2301
2302 #[test]
2303 fn public_key_rejects_unknown_prefix() {
2304 assert!(matches!(
2305 validated_public_key("sk-rsa AAAA... user@host"),
2306 Err(ExecutorError::InvalidParam("public_key"))
2307 ));
2308 assert!(matches!(
2309 validated_public_key("AAAAB3Nz... user@host"),
2310 Err(ExecutorError::InvalidParam("public_key"))
2311 ));
2312 }
2313
2314 #[test]
2315 fn public_key_rejects_single_quote() {
2316 let key = "ssh-ed25519 AAAA' $(rm -rf /) user@host";
2317 assert!(matches!(
2318 validated_public_key(key),
2319 Err(ExecutorError::InvalidParam("public_key"))
2320 ));
2321 }
2322
2323 #[test]
2324 fn public_key_rejects_pipe_sed_injection() {
2325 let key = "ssh-ed25519 AAAA|; rm -rf /etc user@host";
2328 assert!(matches!(
2329 validated_public_key(key),
2330 Err(ExecutorError::InvalidParam("public_key"))
2331 ));
2332 }
2333
2334 #[test]
2335 fn public_key_rejects_shell_metacharacters() {
2336 for (metachar, desc) in [
2337 (';', "semicolon"),
2338 ('`', "backtick"),
2339 ('$', "dollar"),
2340 ('\\', "backslash"),
2341 ('&', "ampersand"),
2342 ] {
2343 let key = format!("ssh-ed25519 AAAA{metachar}injected user@host");
2344 assert!(
2345 matches!(
2346 validated_public_key(&key),
2347 Err(ExecutorError::InvalidParam("public_key"))
2348 ),
2349 "{desc} should be rejected"
2350 );
2351 }
2352 }
2353
2354 #[test]
2355 fn public_key_rejects_newline() {
2356 let key = "ssh-ed25519 AAAA\nmalicious: line user@host";
2357 assert!(matches!(
2358 validated_public_key(key),
2359 Err(ExecutorError::InvalidParam("public_key"))
2360 ));
2361 let key_cr = "ssh-ed25519 AAAA\rmalicious: line user@host";
2362 assert!(matches!(
2363 validated_public_key(key_cr),
2364 Err(ExecutorError::InvalidParam("public_key"))
2365 ));
2366 }
2367
2368 #[test]
2369 fn public_key_rejects_too_long() {
2370 let long_payload = "A".repeat(8192);
2372 let key = format!("ssh-ed25519 {long_payload} user@host");
2373 assert!(matches!(
2374 validated_public_key(&key),
2375 Err(ExecutorError::InvalidParam("public_key"))
2376 ));
2377 }
2378
2379 #[test]
2382 fn str_array_or_empty_rejects_non_string_element() {
2383 let params = json!({ "packages": ["vim", 42, "curl"] });
2384 assert!(matches!(
2385 str_array_or_empty(¶ms, "packages"),
2386 Err(ExecutorError::InvalidParam("packages"))
2387 ));
2388 }
2389
2390 #[test]
2391 fn str_array_or_empty_accepts_string_array() {
2392 let params = json!({ "packages": ["vim", "curl"] });
2393 assert_eq!(
2394 str_array_or_empty(¶ms, "packages").unwrap(),
2395 vec!["vim".to_string(), "curl".to_string()]
2396 );
2397 }
2398
2399 #[test]
2400 fn str_array_or_empty_returns_empty_when_key_absent() {
2401 let params = json!({});
2402 assert_eq!(
2403 str_array_or_empty(¶ms, "packages").unwrap(),
2404 Vec::<String>::new()
2405 );
2406 }
2407
2408 #[test]
2411 fn kernel_arg_allows_safe_args() {
2412 assert!(validated_safe_kernel_arg("quiet", "add").is_ok());
2413 assert!(validated_safe_kernel_arg("mitigations=off", "add").is_ok());
2414 assert!(validated_safe_kernel_arg("rd.driver.blacklist=nouveau", "add").is_ok());
2415 assert!(validated_safe_kernel_arg("console=ttyS0,115200", "add").is_ok());
2416 }
2417
2418 #[test]
2419 fn kernel_arg_blocks_init_override() {
2420 assert!(matches!(
2421 validated_safe_kernel_arg("init=/bin/sh", "add"),
2422 Err(ExecutorError::InvalidParam("add"))
2423 ));
2424 assert!(matches!(
2425 validated_safe_kernel_arg("INIT=/sbin/bash", "add"),
2426 Err(ExecutorError::InvalidParam("add"))
2427 ));
2428 }
2429
2430 #[test]
2431 fn kernel_arg_blocks_selinux_disable() {
2432 assert!(matches!(
2433 validated_safe_kernel_arg("selinux=0", "add"),
2434 Err(ExecutorError::InvalidParam("add"))
2435 ));
2436 assert!(matches!(
2437 validated_safe_kernel_arg("enforcing=0", "add"),
2438 Err(ExecutorError::InvalidParam("add"))
2439 ));
2440 }
2441
2442 #[test]
2443 fn kernel_arg_blocks_security_override() {
2444 assert!(matches!(
2445 validated_safe_kernel_arg("security=none", "add"),
2446 Err(ExecutorError::InvalidParam("add"))
2447 ));
2448 }
2449
2450 #[test]
2451 fn kernel_arg_blocks_module_blacklist() {
2452 assert!(matches!(
2453 validated_safe_kernel_arg("module_blacklist=dm_crypt", "add"),
2454 Err(ExecutorError::InvalidParam("add"))
2455 ));
2456 }
2457
2458 #[test]
2459 fn kernel_arg_blocks_systemd_unit_emergency_rescue() {
2460 assert!(matches!(
2461 validated_safe_kernel_arg("systemd.unit=emergency.target", "add"),
2462 Err(ExecutorError::InvalidParam("add"))
2463 ));
2464 assert!(matches!(
2465 validated_safe_kernel_arg("systemd.unit=rescue.target", "add"),
2466 Err(ExecutorError::InvalidParam("add"))
2467 ));
2468 assert!(matches!(
2469 validated_safe_kernel_arg("systemd.unit=single.target", "add"),
2470 Err(ExecutorError::InvalidParam("add"))
2471 ));
2472 }
2473
2474 #[test]
2475 fn kernel_arg_blocks_single_user_shortcuts() {
2476 assert!(matches!(
2478 validated_safe_kernel_arg("single", "add"),
2479 Err(ExecutorError::InvalidParam("add"))
2480 ));
2481 assert!(matches!(
2482 validated_safe_kernel_arg("s", "add"),
2483 Err(ExecutorError::InvalidParam("add"))
2484 ));
2485 assert!(matches!(
2486 validated_safe_kernel_arg("1", "add"),
2487 Err(ExecutorError::InvalidParam("add"))
2488 ));
2489 }
2490
2491 #[test]
2492 fn signal_process_guardrails() {
2493 for bad_pid in [0, 1] {
2495 let err = build_action_spec("SignalProcess", &json!({ "pid": bad_pid })).unwrap_err();
2496 assert!(
2497 matches!(err, ExecutorError::InvalidParam("pid")),
2498 "pid {bad_pid} must be rejected, got {err:?}"
2499 );
2500 }
2501 let err = build_action_spec("SignalProcess", &json!({ "pid": 4242, "signal": "STOP" }))
2503 .unwrap_err();
2504 assert!(
2505 matches!(err, ExecutorError::InvalidParam("signal")),
2506 "got {err:?}"
2507 );
2508 assert!(matches!(
2510 build_action_spec("SignalProcess", &json!({})).unwrap_err(),
2511 ExecutorError::MissingParam("pid")
2512 ));
2513 let spec = build_action_spec(
2515 "SignalProcess",
2516 &json!({ "pid": "4242", "signal": "sigkill" }),
2517 )
2518 .unwrap();
2519 assert_eq!(spec.action_name, "SignalProcess");
2520 assert_eq!(
2521 spec.mechanism,
2522 ActionMechanism::Command {
2523 program: "sudo",
2524 args: vec![
2525 "kill".to_string(),
2526 "-s".to_string(),
2527 "KILL".to_string(),
2528 "4242".to_string()
2529 ],
2530 }
2531 );
2532 let spec = build_action_spec("SignalProcess", &json!({ "pid": 4242 })).unwrap();
2534 if let ActionMechanism::Command { args, .. } = spec.mechanism {
2535 assert_eq!(args, vec!["kill", "-s", "TERM", "4242"]);
2536 } else {
2537 panic!("expected Command mechanism");
2538 }
2539 }
2540
2541 #[test]
2542 fn create_scheduled_job_validates_name_command_schedule() {
2543 assert!(matches!(
2545 build_action_spec(
2546 "CreateScheduledJob",
2547 &json!({ "name": "backup", "command": "/bin/true\nExecStartPre=/evil", "schedule": "daily" })
2548 )
2549 .unwrap_err(),
2550 ExecutorError::InvalidParam("command")
2551 ));
2552 assert!(matches!(
2554 build_action_spec(
2555 "CreateScheduledJob",
2556 &json!({ "name": "../evil", "command": "/bin/true", "schedule": "daily" })
2557 )
2558 .unwrap_err(),
2559 ExecutorError::InvalidParam("name")
2560 ));
2561 assert!(matches!(
2563 build_action_spec(
2564 "CreateScheduledJob",
2565 &json!({ "name": "backup", "command": "/bin/true", "schedule": "daily; rm -rf /" })
2566 )
2567 .unwrap_err(),
2568 ExecutorError::InvalidParam("schedule")
2569 ));
2570 let spec = build_action_spec(
2572 "CreateScheduledJob",
2573 &json!({ "name": "nightly-backup", "command": "/usr/bin/backup --full", "schedule": "*-*-* 02:00:00" }),
2574 )
2575 .unwrap();
2576 assert_eq!(
2577 spec.mechanism,
2578 ActionMechanism::Command {
2579 program: "sudo",
2580 args: vec![
2581 "/usr/lib/sysknife/scheduled-job-edit".to_string(),
2582 "--name".to_string(),
2583 "nightly-backup".to_string(),
2584 "--command".to_string(),
2585 "/usr/bin/backup --full".to_string(),
2586 "--schedule".to_string(),
2587 "*-*-* 02:00:00".to_string(),
2588 ],
2589 }
2590 );
2591 }
2592
2593 #[test]
2594 fn set_sshd_option_allowlist_guardrails() {
2595 assert!(matches!(
2597 build_action_spec(
2598 "SetSshdOption",
2599 &json!({ "option": "Ciphers", "value": "aes256-gcm@openssh.com" })
2600 )
2601 .unwrap_err(),
2602 ExecutorError::InvalidParam("option")
2603 ));
2604 assert!(matches!(
2606 build_action_spec(
2607 "SetSshdOption",
2608 &json!({ "option": "PermitRootLogin", "value": "maybe" })
2609 )
2610 .unwrap_err(),
2611 ExecutorError::InvalidParam("value")
2612 ));
2613 let spec = build_action_spec(
2615 "SetSshdOption",
2616 &json!({ "option": "PasswordAuthentication", "value": "no" }),
2617 )
2618 .unwrap();
2619 assert_eq!(
2620 spec.mechanism,
2621 ActionMechanism::Command {
2622 program: "sudo",
2623 args: vec![
2624 "/usr/lib/sysknife/sshd-option-edit".to_string(),
2625 "--option".to_string(),
2626 "PasswordAuthentication".to_string(),
2627 "--value".to_string(),
2628 "no".to_string(),
2629 ],
2630 }
2631 );
2632 }
2633
2634 #[test]
2635 fn configure_unattended_upgrades_toggles_helper_flag() {
2636 for (enabled, flag) in [(true, "--enable"), (false, "--disable")] {
2637 let spec = build_action_spec(
2638 "ConfigureUnattendedUpgrades",
2639 &json!({ "enabled": enabled }),
2640 )
2641 .unwrap();
2642 assert_eq!(
2643 spec.mechanism,
2644 ActionMechanism::Command {
2645 program: "sudo",
2646 args: vec![
2647 "/usr/lib/sysknife/unattended-upgrades-edit".to_string(),
2648 flag.to_string(),
2649 ],
2650 }
2651 );
2652 }
2653 assert!(matches!(
2654 build_action_spec("ConfigureUnattendedUpgrades", &json!({})).unwrap_err(),
2655 ExecutorError::MissingParam("enabled")
2656 ));
2657 }
2658
2659 #[test]
2660 fn grub_set_kargs_append_blocks_single_user_shortcut() {
2661 for dangerous in ["single", "s", "1"] {
2666 let err = build_action_spec(
2667 "GrubSetKargs",
2668 &json!({ "append": [dangerous], "delete": [] }),
2669 )
2670 .unwrap_err();
2671 assert!(
2672 matches!(err, ExecutorError::InvalidParam("append")),
2673 "GrubSetKargs must reject append=[{dangerous:?}] via the denylist, got {err:?}"
2674 );
2675 }
2676 assert!(build_action_spec(
2678 "GrubSetKargs",
2679 &json!({ "append": ["quiet"], "delete": [] })
2680 )
2681 .is_ok());
2682 }
2683
2684 #[test]
2685 fn kernel_arg_build_spec_rejects_dangerous_arg() {
2686 let err = build_action_spec(
2688 "SetKernelArguments",
2689 &json!({ "add": ["init=/bin/bash"], "remove": [] }),
2690 )
2691 .unwrap_err();
2692 assert!(
2693 matches!(err, ExecutorError::InvalidParam("add")),
2694 "expected InvalidParam(add), got {err:?}"
2695 );
2696 }
2697
2698 #[test]
2703 fn rollback_available_matches_rollback_spec_for_all_actions() {
2704 let all_specs: Vec<ActionSpec> = containers::specs()
2705 .into_iter()
2706 .chain(deployment::specs())
2707 .chain(filesystem::specs())
2708 .chain(flatpak::specs())
2709 .chain(identity::specs())
2710 .chain(layering::specs())
2711 .chain(network::specs())
2712 .chain(package_repos::specs())
2713 .chain(processes::specs())
2714 .chain(services::specs())
2715 .chain(ssh::specs())
2716 .chain(system_info::specs())
2717 .chain(toolbox::specs())
2718 .chain(users::specs())
2719 .collect();
2720
2721 for spec in &all_specs {
2722 let has_rollback = rollback_spec_for(spec.action_name).is_some();
2723 assert_eq!(
2724 spec.rollback_available,
2725 has_rollback,
2726 "action {:?}: rollback_available={} but rollback_spec_for returns {}",
2727 spec.action_name,
2728 spec.rollback_available,
2729 if has_rollback { "Some" } else { "None" },
2730 );
2731 }
2732 }
2733
2734 #[test]
2740 fn create_user_is_high_risk() {
2741 let spec = build_action_spec("CreateUser", &json!({ "username": "alice" })).unwrap();
2742 assert_eq!(
2743 spec.risk_level,
2744 RiskLevel::High,
2745 "CreateUser must be High (T1136.001 Persistence)"
2746 );
2747 }
2748
2749 #[test]
2750 fn configure_firewall_is_high_risk() {
2751 let spec = build_action_spec(
2752 "ConfigureFirewall",
2753 &json!({ "zone": "public", "service": "ssh", "enabled": true }),
2754 )
2755 .unwrap();
2756 assert_eq!(
2757 spec.risk_level,
2758 RiskLevel::High,
2759 "ConfigureFirewall must be High (T1562.004 Defense Evasion)"
2760 );
2761 }
2762
2763 #[test]
2764 fn mask_service_is_high_risk() {
2765 let spec = build_action_spec("MaskService", &json!({ "unit": "auditd.service" })).unwrap();
2766 assert_eq!(
2767 spec.risk_level,
2768 RiskLevel::High,
2769 "MaskService must be High (T1562.001 Impair Defenses)"
2770 );
2771 }
2772
2773 #[test]
2774 fn add_package_repository_is_high_risk() {
2775 let spec = build_action_spec(
2776 "AddPackageRepository",
2777 &json!({ "repo_id": "my-repo", "repo_url": "https://ok.example/repo" }),
2778 )
2779 .unwrap();
2780 assert_eq!(
2781 spec.risk_level,
2782 RiskLevel::High,
2783 "AddPackageRepository must be High (supply-chain vector)"
2784 );
2785 }
2786
2787 #[test]
2788 fn set_dns_servers_is_high_risk() {
2789 let spec = build_action_spec(
2790 "SetDnsServers",
2791 &json!({ "interface": "eth0", "servers": ["8.8.8.8"] }),
2792 )
2793 .unwrap();
2794 assert_eq!(
2795 spec.risk_level,
2796 RiskLevel::High,
2797 "SetDnsServers must be High (DNS hijacking / T1557)"
2798 );
2799 }
2800}