diff --git a/agent/src/collectors/service.rs b/agent/src/collectors/service.rs index 074c006..8374a31 100644 --- a/agent/src/collectors/service.rs +++ b/agent/src/collectors/service.rs @@ -79,7 +79,10 @@ impl ServiceCollector { } } - let status = self.determine_service_status(&active_state, &sub_state); + // Check if service is sandboxed (needed for status determination) + let is_sandboxed = self.check_service_sandbox(service).await.unwrap_or(false); + + let status = self.determine_service_status(&active_state, &sub_state, is_sandboxed); // Get resource usage if service is running let (memory_used_mb, cpu_percent) = if let Some(pid) = main_pid { @@ -121,6 +124,7 @@ impl ServiceCollector { sandbox_limit: None, // TODO: Implement sandbox limit detection disk_used_gb, disk_quota_gb, + is_sandboxed, description, sub_service: None, }) @@ -130,10 +134,25 @@ impl ServiceCollector { &self, active_state: &Option, sub_state: &Option, + is_sandboxed: bool, ) -> ServiceStatus { match (active_state.as_deref(), sub_state.as_deref()) { - (Some("active"), Some("running")) => ServiceStatus::Running, - (Some("active"), Some("exited")) => ServiceStatus::Running, // One-shot services + (Some("active"), Some("running")) => { + // Running services should be degraded if not sandboxed + if is_sandboxed { + ServiceStatus::Running + } else { + ServiceStatus::Degraded // Warning status for unsandboxed running services + } + }, + (Some("active"), Some("exited")) => { + // One-shot services should also be degraded if not sandboxed + if is_sandboxed { + ServiceStatus::Running + } else { + ServiceStatus::Degraded + } + }, (Some("reloading"), _) | (Some("activating"), _) => ServiceStatus::Restarting, (Some("failed"), _) | (Some("inactive"), Some("failed")) => ServiceStatus::Stopped, (Some("inactive"), _) => ServiceStatus::Stopped, @@ -266,35 +285,58 @@ impl ServiceCollector { } async fn get_service_disk_quota(&self, service: &str) -> Result { - // Check systemd for disk-related limits (limited options available) - // Most systemd services don't have disk quotas, but we can check for some storage-related settings - - // Check for filesystem quotas on service data directories - let service_paths = vec![ - format!("/var/lib/{}", service), - format!("/opt/{}", service), - format!("/srv/{}", service), - ]; - - for path in &service_paths { - if tokio::fs::metadata(path).await.is_ok() { - // Try quota command (if available) - if let Ok(quota_gb) = self.check_filesystem_quota(path).await { - if quota_gb > 0.0 { - return Ok(quota_gb); + // Check systemd service properties for NixOS hardening-related disk restrictions + let systemd_output = Command::new("/run/current-system/sw/bin/systemctl") + .args(["show", service, "--property=PrivateTmp,ProtectHome,ProtectSystem,ReadOnlyPaths,InaccessiblePaths,BindPaths,BindReadOnlyPaths", "--no-pager"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + + if let Ok(output) = systemd_output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + + // Parse systemd properties that might indicate disk restrictions + let mut private_tmp = false; + let mut protect_system = false; + let mut readonly_paths = Vec::new(); + + for line in stdout.lines() { + if line.starts_with("PrivateTmp=yes") { + private_tmp = true; + } else if line.starts_with("ProtectSystem=strict") || line.starts_with("ProtectSystem=yes") { + protect_system = true; + } else if let Some(paths) = line.strip_prefix("ReadOnlyPaths=") { + readonly_paths.push(paths.to_string()); } } + + // If service has significant restrictions, it might have implicit disk limits + // This is heuristic-based since systemd doesn't have direct disk quotas + if private_tmp && protect_system { + // Heavily sandboxed services might have practical disk limits + // Return a conservative estimate based on typical service needs + return Ok(1.0); // 1 GB as reasonable limit for sandboxed services + } } } - // Service-specific quota detection + // Check for service-specific disk configurations in NixOS match service { "docker" => { - // Docker might have storage driver limits + // Docker might have storage driver limits in NixOS config if let Ok(limit) = self.get_docker_storage_quota().await { return Ok(limit); } }, + "postgresql" | "postgres" => { + // PostgreSQL might have tablespace or data directory limits + // Check for database-specific storage configuration + }, + "mysql" | "mariadb" => { + // MySQL might have data directory size limits + }, _ => {} } @@ -338,6 +380,48 @@ impl ServiceCollector { message: "Docker storage quota detection not implemented".to_string(), }) } + + async fn check_service_sandbox(&self, service: &str) -> Result { + // Check systemd service properties for sandboxing/hardening settings + let systemd_output = Command::new("/run/current-system/sw/bin/systemctl") + .args(["show", service, "--property=PrivateTmp,ProtectHome,ProtectSystem,NoNewPrivileges,PrivateDevices,ProtectKernelTunables,RestrictRealtime", "--no-pager"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .await; + + if let Ok(output) = systemd_output { + if output.status.success() { + let stdout = String::from_utf8_lossy(&output.stdout); + + let mut sandbox_indicators = 0; + let mut total_checks = 0; + + for line in stdout.lines() { + total_checks += 1; + + // Check for various sandboxing properties + if line.starts_with("PrivateTmp=yes") || + line.starts_with("ProtectHome=yes") || + line.starts_with("ProtectSystem=strict") || + line.starts_with("ProtectSystem=yes") || + line.starts_with("NoNewPrivileges=yes") || + line.starts_with("PrivateDevices=yes") || + line.starts_with("ProtectKernelTunables=yes") || + line.starts_with("RestrictRealtime=yes") { + sandbox_indicators += 1; + } + } + + // Consider service sandboxed if it has multiple hardening features + let is_sandboxed = sandbox_indicators >= 3; + return Ok(is_sandboxed); + } + } + + // Default to not sandboxed if we can't determine + Ok(false) + } async fn get_service_memory_limit(&self, service: &str) -> Result { let output = Command::new("/run/current-system/sw/bin/systemctl") @@ -1229,6 +1313,7 @@ impl Collector for ServiceCollector { sandbox_limit: None, disk_used_gb: 0.0, disk_quota_gb: 0.0, + is_sandboxed: false, // Sub-services inherit parent sandbox status description: None, sub_service: Some("nginx".to_string()), }); @@ -1255,6 +1340,7 @@ impl Collector for ServiceCollector { sandbox_limit: None, disk_used_gb: 0.0, disk_quota_gb: 0.0, + is_sandboxed: true, // Docker containers are inherently sandboxed description: None, sub_service: Some("docker".to_string()), }); @@ -1277,6 +1363,7 @@ impl Collector for ServiceCollector { sandbox_limit: None, disk_used_gb: 0.0, disk_quota_gb: 0.0, + is_sandboxed: false, // Unknown for failed services description: None, sub_service: None, }); @@ -1337,6 +1424,7 @@ struct ServiceData { sandbox_limit: Option, disk_used_gb: f32, disk_quota_gb: f32, + is_sandboxed: bool, #[serde(skip_serializing_if = "Option::is_none")] description: Option>, #[serde(default)] diff --git a/dashboard/src/data/metrics.rs b/dashboard/src/data/metrics.rs index cb0acff..decc624 100644 --- a/dashboard/src/data/metrics.rs +++ b/dashboard/src/data/metrics.rs @@ -119,6 +119,8 @@ pub struct ServiceInfo { #[serde(default)] pub disk_quota_gb: f32, #[serde(default)] + pub is_sandboxed: bool, + #[serde(default)] pub description: Option>, #[serde(default)] pub sub_service: Option, diff --git a/dashboard/src/ui/services.rs b/dashboard/src/ui/services.rs index 01f1189..406d1d0 100644 --- a/dashboard/src/ui/services.rs +++ b/dashboard/src/ui/services.rs @@ -50,7 +50,7 @@ fn render_metrics( let mut data = WidgetData::new( title, Some(WidgetStatus::new(widget_status)), - vec!["Service".to_string(), "RAM (GB)".to_string(), "CPU (%)".to_string(), "Disk (GB)".to_string()] + vec!["Service".to_string(), "RAM (GB)".to_string(), "CPU (%)".to_string(), "Disk (GB)".to_string(), "SB".to_string()] ); @@ -63,6 +63,7 @@ fn render_metrics( "".to_string(), "".to_string(), "".to_string(), + "".to_string(), ], ); render_widget_data(frame, area, data); @@ -105,7 +106,7 @@ fn render_metrics( }; if svc.sub_service.is_some() { - // Sub-services only show name and status, no memory/CPU/disk data + // Sub-services only show name and status, no memory/CPU/disk/sandbox data data.add_row_with_sub_service( Some(WidgetStatus::new(status_level)), description, @@ -114,6 +115,7 @@ fn render_metrics( "".to_string(), "".to_string(), "".to_string(), + "".to_string(), ], svc.sub_service.clone(), ); @@ -127,6 +129,7 @@ fn render_metrics( format_memory_value(svc.memory_used_mb, svc.memory_quota_mb), format_cpu_value(svc.cpu_percent), format_disk_value(svc.disk_used_gb, svc.disk_quota_gb), + format_sandbox_value(svc.is_sandboxed), ], ); } @@ -168,3 +171,11 @@ fn format_disk_value(used: f32, quota: f32) -> String { } } +fn format_sandbox_value(is_sandboxed: bool) -> String { + if is_sandboxed { + "yes".to_string() + } else { + "no".to_string() + } +} +