feat: initial commit of diagd - unified Linux/BSD/macOS/Windows diagnostic tool (TUI + GUI)
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "diagd-core"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Core diagnostic engine for Linux and BSD systems"
|
||||
|
||||
[dependencies]
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
regex = "1.10"
|
||||
reqwest = { version = "0.12", features = ["json"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
libc = "0.2"
|
||||
flate2 = "1.0"
|
||||
tar = "0.4"
|
||||
@@ -0,0 +1,810 @@
|
||||
use crate::models::{
|
||||
DiagnosticIssue, FixSuggestion, IssueItem, Severity, Subsystem, SubsystemStatus, SystemReport,
|
||||
};
|
||||
use crate::sanitize::DataSanitizer;
|
||||
use crate::scanner::UnifiedRawData;
|
||||
use serde::Serialize;
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LlmDiagnosticPrompt {
|
||||
pub role: String,
|
||||
pub system_instruction: String,
|
||||
pub os_family: String,
|
||||
pub init_system: String,
|
||||
pub is_root: bool,
|
||||
pub sanitized_context: SanitizedContextPayload,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct SanitizedContextPayload {
|
||||
pub failed_services: Vec<FailedServiceSummary>,
|
||||
pub kernel_summary: KernelSummaryPayload,
|
||||
pub system_limits: LimitsSummaryPayload,
|
||||
pub storage_alerts: Vec<String>,
|
||||
pub hardware_summary: HardwareSummaryPayload,
|
||||
pub network_summary: NetworkSummaryPayload,
|
||||
pub raw_sanitized_logs: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct HardwareSummaryPayload {
|
||||
pub max_cpu_temp_c: f32,
|
||||
pub thermal_throttled_cores: usize,
|
||||
pub total_throttle_events: u64,
|
||||
pub is_actively_throttling: bool,
|
||||
pub battery_degraded: bool,
|
||||
pub edac_memory_errors: u64,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct NetworkSummaryPayload {
|
||||
pub dns_broken_symlink: bool,
|
||||
pub dns_resolution_ok: bool,
|
||||
pub dns_latency_ms: Option<u128>,
|
||||
pub has_default_gateway: bool,
|
||||
pub firewall_conflict: Option<String>,
|
||||
pub wifi_drops_count: usize,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct FailedServiceSummary {
|
||||
pub name: String,
|
||||
pub init_system: String,
|
||||
pub state: String,
|
||||
pub error_snippet: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct KernelSummaryPayload {
|
||||
pub tainted_flags: Vec<String>,
|
||||
pub oom_events_count: usize,
|
||||
pub gpu_hangs_count: usize,
|
||||
pub io_errors_count: usize,
|
||||
pub csum_errors_count: usize,
|
||||
pub permission_denied: bool,
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct LimitsSummaryPayload {
|
||||
pub file_descriptors_exhausted: bool,
|
||||
pub recent_coredumps: Vec<String>,
|
||||
pub memory_pressure_avg10: f32,
|
||||
pub io_pressure_avg10: f32,
|
||||
}
|
||||
|
||||
impl LlmDiagnosticPrompt {
|
||||
pub fn build(data: &UnifiedRawData) -> (String, Self) {
|
||||
let failed_summary: Vec<FailedServiceSummary> = data
|
||||
.failed_services
|
||||
.iter()
|
||||
.map(|s| FailedServiceSummary {
|
||||
name: s.name.clone(),
|
||||
init_system: s.init_system.clone(),
|
||||
state: s.state.clone(),
|
||||
error_snippet: DataSanitizer::sanitize(&s.logs)
|
||||
.lines()
|
||||
.take(6)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
})
|
||||
.collect();
|
||||
|
||||
let kernel_summary = KernelSummaryPayload {
|
||||
tainted_flags: data.kernel_scan.tainted_flags.clone(),
|
||||
oom_events_count: data.kernel_scan.oom_events.len(),
|
||||
gpu_hangs_count: data.kernel_scan.gpu_hangs.len(),
|
||||
io_errors_count: data.kernel_scan.io_errors.len(),
|
||||
csum_errors_count: data.kernel_scan.csum_errors.len(),
|
||||
permission_denied: data.kernel_scan.permission_denied,
|
||||
};
|
||||
|
||||
let limits_summary = LimitsSummaryPayload {
|
||||
file_descriptors_exhausted: data.limits_scan.file_descriptors_exhausted,
|
||||
recent_coredumps: data.limits_scan.recent_coredumps.clone(),
|
||||
memory_pressure_avg10: data.pressure_scan.mem_some_avg10,
|
||||
io_pressure_avg10: data.pressure_scan.io_some_avg10,
|
||||
};
|
||||
|
||||
let storage_alerts: Vec<String> = data
|
||||
.storage_mounts
|
||||
.iter()
|
||||
.filter(|m| m.is_critical)
|
||||
.map(|m| {
|
||||
format!(
|
||||
"Mount {}: space used {:.1}%, inode used {:.1}%",
|
||||
m.mount_point, m.used_percent, m.inode_used_percent
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let hardware_summary = HardwareSummaryPayload {
|
||||
max_cpu_temp_c: data.hardware_scan.max_cpu_temp_c,
|
||||
thermal_throttled_cores: data.hardware_scan.thermal_throttled_cores,
|
||||
total_throttle_events: data.hardware_scan.total_throttle_events,
|
||||
is_actively_throttling: data.hardware_scan.is_actively_throttling,
|
||||
battery_degraded: data.hardware_scan.battery_health.as_ref().map(|b| b.is_degraded).unwrap_or(false),
|
||||
edac_memory_errors: data.hardware_scan.edac_ce_count + data.hardware_scan.edac_ue_count,
|
||||
};
|
||||
|
||||
let network_summary = NetworkSummaryPayload {
|
||||
dns_broken_symlink: data.network_deep_scan.dns_broken_symlink,
|
||||
dns_resolution_ok: data.network_deep_scan.dns_resolution_ok,
|
||||
dns_latency_ms: data.network_deep_scan.dns_latency_ms,
|
||||
has_default_gateway: data.network_deep_scan.has_default_gateway,
|
||||
firewall_conflict: data.network_deep_scan.firewall_conflict.clone(),
|
||||
wifi_drops_count: data.network_deep_scan.wifi_drop_events.len(),
|
||||
};
|
||||
|
||||
let mut combined_logs = String::new();
|
||||
combined_logs.push_str(&DataSanitizer::sanitize(&data.kernel_scan.raw_logs));
|
||||
combined_logs.push_str("\n");
|
||||
combined_logs.push_str(&DataSanitizer::sanitize(&data.service_logs));
|
||||
|
||||
let payload = SanitizedContextPayload {
|
||||
failed_services: failed_summary,
|
||||
kernel_summary,
|
||||
system_limits: limits_summary,
|
||||
storage_alerts,
|
||||
hardware_summary,
|
||||
network_summary,
|
||||
raw_sanitized_logs: combined_logs,
|
||||
};
|
||||
|
||||
let prompt_obj = Self {
|
||||
role: "Unix/Linux Systems Reliability Engineer & AI Diagnostic Analyst".to_string(),
|
||||
system_instruction: format!(
|
||||
"Analyze the sanitized {} ({}) system diagnostics. Identify all anomalies, assign severity (Critical/Warning/Info), determine exact root cause, and provide precise bash/sh commands specifically tailored for init system '{}' and this OS family.",
|
||||
data.os_name, data.init_name, data.init_name
|
||||
),
|
||||
os_family: data.os_name.clone(),
|
||||
init_system: data.init_name.clone(),
|
||||
is_root: data.is_root,
|
||||
sanitized_context: payload,
|
||||
};
|
||||
|
||||
let json_preview = serde_json::to_string_pretty(&prompt_obj).unwrap_or_default();
|
||||
(json_preview, prompt_obj)
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn try_query_llm(prompt: &str) -> Option<String> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(std::time::Duration::from_secs(4))
|
||||
.build()
|
||||
.ok()?;
|
||||
|
||||
let body = serde_json::json!({
|
||||
"model": "llama3:latest",
|
||||
"prompt": prompt,
|
||||
"stream": false
|
||||
});
|
||||
|
||||
let res = client
|
||||
.post("http://127.0.0.1:11434/api/generate")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
|
||||
if res.status().is_success() {
|
||||
let json_res: serde_json::Value = res.json().await.ok()?;
|
||||
json_res.get("response").and_then(|r| r.as_str()).map(|s| s.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn heuristic_ai_analyze(data: &UnifiedRawData) -> (Vec<IssueItem>, Vec<SubsystemStatus>, String) {
|
||||
let mut issues = Vec::new();
|
||||
|
||||
// 1. Анализ сбойных служб Init (systemd, OpenRC, runit, etc.)
|
||||
let mut init_status = "Healthy".to_string();
|
||||
let mut init_issue_count = 0;
|
||||
|
||||
for (idx, svc) in data.failed_services.iter().enumerate() {
|
||||
init_issue_count += 1;
|
||||
init_status = "Failed".to_string();
|
||||
|
||||
let clean_log = DataSanitizer::sanitize(&svc.logs);
|
||||
let snippet = clean_log
|
||||
.lines()
|
||||
.take(10)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
issues.push(DiagnosticIssue {
|
||||
id: format!("init-{}", idx + 1),
|
||||
title: format!("Служба '{}' в состоянии сбоя ({})", svc.name, svc.init_system),
|
||||
subsystem: Subsystem::Systemd,
|
||||
severity: Severity::Critical,
|
||||
root_cause: format!("Процесс службы {} аварийно остановился под управлением {}.", svc.name, svc.init_system),
|
||||
explanation: svc.description.clone(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: format!("Перезапуск службы {}", svc.name),
|
||||
command: svc.restart_command.clone(),
|
||||
explanation: format!("Команда перезапуска, адаптированная под систему инициализации {}.", svc.init_system),
|
||||
}),
|
||||
raw_log: if snippet.is_empty() { clean_log } else { snippet },
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 2. Анализ Kernel Space
|
||||
let mut kernel_status = "Healthy".to_string();
|
||||
let mut kernel_issue_count = 0;
|
||||
|
||||
if data.kernel_scan.permission_denied {
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "kern-perm".to_string(),
|
||||
title: "Доступ к кольцевому буферу ядра (dmesg) ограничен".to_string(),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: Severity::Warning,
|
||||
root_cause: "В системе включен dmesg_restrict, чтение системных логов ядра требует прав суперпользователя.".to_string(),
|
||||
explanation: "Сканирование продолжено в unprivileged-режиме. Для доступа к полному логу dmesg запустите приложение с правами sudo/pkexec.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Запуск диагностического сканирования с sudo".to_string(),
|
||||
command: "sudo diagd-tui".to_string(),
|
||||
explanation: "Предоставляет приложению временные привилегии для глубокого аудита оборудования.".to_string(),
|
||||
}),
|
||||
raw_log: "Operation not permitted reading dmesg/kmsg".to_string(),
|
||||
sanitized: true,
|
||||
permission_denied: true,
|
||||
});
|
||||
}
|
||||
|
||||
if !data.kernel_scan.oom_events.is_empty() {
|
||||
kernel_status = "Failed".to_string();
|
||||
kernel_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "kern-oom".to_string(),
|
||||
title: "Сработал OOM-Killer: принудительное завершение процессов из-за нехватки памяти".to_string(),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: Severity::Critical,
|
||||
root_cause: "Система исчерпала физическую RAM и swap-пространство, ядро завершило наиболее прожорливые процессы.".to_string(),
|
||||
explanation: "Необходимо проанализировать потребление памяти приложениями или увеличить объем файла подкачки (swap).".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Проверка текущего распределения памяти и процессов".to_string(),
|
||||
command: "free -h && ps aux --sort=-%mem | head -n 10".to_string(),
|
||||
explanation: "Выводит доступную память и топ-10 процессов по потреблению RAM.".to_string(),
|
||||
}),
|
||||
raw_log: data.kernel_scan.oom_events.join("\n"),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !data.kernel_scan.gpu_hangs.is_empty() {
|
||||
kernel_status = "Failed".to_string();
|
||||
kernel_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "kern-gpu".to_string(),
|
||||
title: "Зафиксировано зависание или аварийный сброс видеодрайвера (GPU Hang)".to_string(),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: Severity::Critical,
|
||||
root_cause: "Драйвер графического ускорителя сообщил о таймауте выполнения команд (Xid / amdgpu reset / ring hang).".to_string(),
|
||||
explanation: "Возможен перегрев видеокарты, нестабильность драйвера или аппаратная деградация GPU.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Проверка статуса видеодрайвера".to_string(),
|
||||
command: "nvidia-smi || lspci -k | grep -EA3 'VGA|3D'".to_string(),
|
||||
explanation: "Запрашивает состояние видеоускорителя и используемый драйвер ядра.".to_string(),
|
||||
}),
|
||||
raw_log: data.kernel_scan.gpu_hangs.join("\n"),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !data.kernel_scan.csum_errors.is_empty() {
|
||||
kernel_status = "Failed".to_string();
|
||||
kernel_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "kern-csum".to_string(),
|
||||
title: "Нарушение контрольных сумм данных (Btrfs/ZFS Checksum Error)".to_string(),
|
||||
subsystem: Subsystem::Storage,
|
||||
severity: Severity::Critical,
|
||||
root_cause: "Файловая система обнаружила несовпадение csum при чтении блоков. Возможен 'silent data corruption'.".to_string(),
|
||||
explanation: "Проверьте стабильность модулей оперативной памяти (memtest) и SMART-статус накопителя.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Запуск scrub для верификации и восстановления данных".to_string(),
|
||||
command: "sudo btrfs scrub start / || sudo zpool scrub $(zpool list -H -o name)".to_string(),
|
||||
explanation: "Инициирует полную проверку целостности данных файловой системы.".to_string(),
|
||||
}),
|
||||
raw_log: data.kernel_scan.csum_errors.join("\n"),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Анализ давления PSI и системных лимитов
|
||||
if data.pressure_scan.mem_some_avg10 > 25.0 {
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "psi-mem".to_string(),
|
||||
title: format!("Критическое давление на память (PSI Memory avg10: {:.1}%)", data.pressure_scan.mem_some_avg10),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: Severity::Warning,
|
||||
root_cause: "Процессы проводят значительное время в ожидании выделения страниц оперативной памяти.".to_string(),
|
||||
explanation: "Высокий показатель PSI Memory указывает на активный трэшинг подсистемы подкачки (swap trashing).".to_string(),
|
||||
fix: None,
|
||||
raw_log: format!("PSI: mem_some_avg10={:.2}%, mem_full_avg10={:.2}%", data.pressure_scan.mem_some_avg10, data.pressure_scan.mem_full_avg10),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if data.limits_scan.file_descriptors_exhausted {
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "limit-fd".to_string(),
|
||||
title: "Исчерпание системного лимита файловых дескрипторов (file-nr)".to_string(),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: Severity::Critical,
|
||||
root_cause: format!("Использовано {} из {} файловых дескрипторов (>95%).", data.limits_scan.file_nr_allocated, data.limits_scan.file_nr_max),
|
||||
explanation: "Новые процессы и сетевые сокеты не смогут открываться, вызывая ошибку 'Too many open files'.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Увеличение лимита файловых дескрипторов fs.file-max".to_string(),
|
||||
command: "sudo sysctl -w fs.file-max=2097152".to_string(),
|
||||
explanation: "Увеличивает максимальное количество одновременно открытых файлов в системе.".to_string(),
|
||||
}),
|
||||
raw_log: format!("file-nr allocated: {}, max: {}", data.limits_scan.file_nr_allocated, data.limits_scan.file_nr_max),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !data.limits_scan.recent_coredumps.is_empty() {
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "app-crash".to_string(),
|
||||
title: "Зафиксированы недавние аварийные сбои приложений (Coredumps)".to_string(),
|
||||
subsystem: Subsystem::Systemd,
|
||||
severity: Severity::Warning,
|
||||
root_cause: "Пользовательские или системные программы завершились с сигналом SIGSEGV, SIGABRT или SIGBUS.".to_string(),
|
||||
explanation: "Служба coredumpctl сохранила дампы памяти упавших приложений для отладки.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Просмотр информации о последнем дампе памяти".to_string(),
|
||||
command: "coredumpctl info".to_string(),
|
||||
explanation: "Выводит стек вызовов и причину падения последнего сбойного процесса.".to_string(),
|
||||
}),
|
||||
raw_log: data.limits_scan.recent_coredumps.join("\n"),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Анализ Storage Mounts (дисковое пространство и inode)
|
||||
let mut storage_status = "Healthy".to_string();
|
||||
let mut storage_issue_count = 0;
|
||||
|
||||
for mount in &data.storage_mounts {
|
||||
if mount.is_critical {
|
||||
storage_status = "Failed".to_string();
|
||||
storage_issue_count += 1;
|
||||
|
||||
let reason = if mount.used_percent >= 90.0 {
|
||||
format!("Заполнено {:.1}% дискового пространства на точке монтирования '{}'", mount.used_percent, mount.mount_point)
|
||||
} else {
|
||||
format!("Исчерпано {:.1}% inode на точке монтирования '{}'", mount.inode_used_percent, mount.mount_point)
|
||||
};
|
||||
|
||||
issues.push(DiagnosticIssue {
|
||||
id: format!("stor-{}", mount.mount_point.replace('/', "-")),
|
||||
title: reason.clone(),
|
||||
subsystem: Subsystem::Storage,
|
||||
severity: Severity::Critical,
|
||||
root_cause: reason,
|
||||
explanation: format!("Переполнение раздела {} ({}) может заблокировать запись журналов и работу системных баз данных.", mount.mount_point, mount.filesystem),
|
||||
fix: Some(FixSuggestion {
|
||||
title: format!("Поиск 10 самых объемных директорий на {}", mount.mount_point),
|
||||
command: format!("sudo du -xh --max-depth=1 {} | sort -hr | head -n 10", mount.mount_point),
|
||||
explanation: "Локализует каталоги, занимающие максимальное пространство на проблемном разделе.".to_string(),
|
||||
}),
|
||||
raw_log: format!("Mount: {}, FSType: {}, Used: {:.1}%, Free: {} MB, Inodes Free: {}", mount.mount_point, mount.filesystem, mount.used_percent, mount.free_bytes / (1024 * 1024), mount.inodes_free),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Анализ аппаратного обеспечения и перегрева (Hardware & Thermals)
|
||||
if data.hardware_scan.total_throttle_events > 0 || data.hardware_scan.max_cpu_temp_c >= 90.0 {
|
||||
kernel_issue_count += 1;
|
||||
let is_crit = data.hardware_scan.max_cpu_temp_c >= 90.0;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "hw-throttling".to_string(),
|
||||
title: format!(
|
||||
"Зафиксирован Thermal Throttling CPU (макс. темп: {:.1}°C, событий: {})",
|
||||
data.hardware_scan.max_cpu_temp_c, data.hardware_scan.total_throttle_events
|
||||
),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: if is_crit { Severity::Critical } else { Severity::Warning },
|
||||
root_cause: format!(
|
||||
"Температура ядер процессора достигла лимита троттлинга. Ядро зафиксировало {} событий сброса частоты на {} ядрах.",
|
||||
data.hardware_scan.total_throttle_events, data.hardware_scan.thermal_throttled_cores
|
||||
),
|
||||
explanation: "Длительный перегрев приводит к деградации кремния и просадкам производительности. Проверьте кулеры и термопасту.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Мониторинг температур датчиков в реальном времени".to_string(),
|
||||
command: "sensors || cat /sys/class/thermal/thermal_zone*/temp".to_string(),
|
||||
explanation: "Отображает текущую температуру ядер и обороты вентиляторов охлаждения.".to_string(),
|
||||
}),
|
||||
raw_log: format!(
|
||||
"CPU Temp: {:.1} C, Throttled Cores: {}, Total Events: {}",
|
||||
data.hardware_scan.max_cpu_temp_c,
|
||||
data.hardware_scan.thermal_throttled_cores,
|
||||
data.hardware_scan.total_throttle_events
|
||||
),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref batt) = data.hardware_scan.battery_health {
|
||||
if batt.is_degraded {
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "hw-battery".to_string(),
|
||||
title: format!("Деградация аккумулятора {} (остаток: {:.1}%, циклов: {})", batt.name, batt.health_percent, batt.cycle_count),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: Severity::Warning,
|
||||
root_cause: format!("Здоровье батареи снизилось до {:.1}% от проектной емкости при {} циклах перезарядки.", batt.health_percent, batt.cycle_count),
|
||||
explanation: "Износ батареи может приводить к внезапным отключениям ноутбука под пиковой нагрузкой.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Подробный отчет энергопотребления".to_string(),
|
||||
command: "upower -i $(upower -e | grep 'BAT')".to_string(),
|
||||
explanation: "Показывает детальную статистику батареи и контроллера питания.".to_string(),
|
||||
}),
|
||||
raw_log: format!("Battery: {}, Capacity: {}%, Health: {:.1}%, Cycles: {}", batt.name, batt.capacity_percent, batt.health_percent, batt.cycle_count),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if data.hardware_scan.edac_ue_count > 0 {
|
||||
kernel_status = "Failed".to_string();
|
||||
kernel_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "hw-edac-ue".to_string(),
|
||||
title: format!("Критические неисправимые ошибки памяти (EDAC UE: {})", data.hardware_scan.edac_ue_count),
|
||||
subsystem: Subsystem::Kernel,
|
||||
severity: Severity::Critical,
|
||||
root_cause: "Контроллер памяти зафиксировал неисправимые (Uncorrected) аппаратные сбои DRAM.".to_string(),
|
||||
explanation: "Возможен дефект планки оперативной памяти. Рекомендуется немедленная замена модуля RAM.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Просмотр журналов EDAC".to_string(),
|
||||
command: "edac-util -v || dmesg | grep -i edac".to_string(),
|
||||
explanation: "Локализует проблемный слот памяти (DIMM / Channel).".to_string(),
|
||||
}),
|
||||
raw_log: format!("EDAC Uncorrected Errors: {}, Corrected Errors: {}", data.hardware_scan.edac_ue_count, data.hardware_scan.edac_ce_count),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 6. Анализ сети, DNS и брандмауэра (Network & DNS)
|
||||
let mut network_status = "Healthy".to_string();
|
||||
let mut network_issue_count = 0;
|
||||
|
||||
if data.network_deep_scan.dns_broken_symlink {
|
||||
network_status = "Failed".to_string();
|
||||
network_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "net-resolv-symlink".to_string(),
|
||||
title: "Сломанная ссылка /etc/resolv.conf (битый симлинк DNS)".to_string(),
|
||||
subsystem: Subsystem::Network,
|
||||
severity: Severity::Critical,
|
||||
root_cause: "Файл /etc/resolv.conf указывает на несуществующий целевой путь (systemd-resolved или сетевой менеджер не инициализирован).".to_string(),
|
||||
explanation: "Программы не могут преобразовать доменные имена в IP-адреса.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Восстановление конфигурации resolv.conf".to_string(),
|
||||
command: "sudo ln -sf /run/systemd/resolve/stub-resolv.conf /etc/resolv.conf || echo 'nameserver 1.1.1.1' | sudo tee /etc/resolv.conf".to_string(),
|
||||
explanation: "Пересоздает симлинк на stub-резолвер systemd или задает публичный DNS.".to_string(),
|
||||
}),
|
||||
raw_log: "Symlink /etc/resolv.conf is dangling".to_string(),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !data.network_deep_scan.dns_resolution_ok {
|
||||
network_status = "Failed".to_string();
|
||||
network_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "net-dns-fail".to_string(),
|
||||
title: "Сбой разрешения DNS-имен (DNS Lookup Failed)".to_string(),
|
||||
subsystem: Subsystem::Network,
|
||||
severity: Severity::Critical,
|
||||
root_cause: format!("Ни один из настроенных DNS-серверов {:?} не отвечает на запросы разрешения имен.", data.network_deep_scan.dns_servers),
|
||||
explanation: "Интернет-соединение может быть физически доступно, но браузеры и сервисы не открывают сайты по доменам.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Проверка статуса DNS резолвера".to_string(),
|
||||
command: "resolvectl status || nslookup cloudflare.com".to_string(),
|
||||
explanation: "Диагностирует опрос апстрим DNS-серверов.".to_string(),
|
||||
}),
|
||||
raw_log: format!("DNS servers: {:?}, resolution_ok: false", data.network_deep_scan.dns_servers),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !data.network_deep_scan.has_default_gateway {
|
||||
network_status = "Failed".to_string();
|
||||
network_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "net-no-gateway".to_string(),
|
||||
title: "Отсутствует маршрут по умолчанию (Default Gateway Missing)".to_string(),
|
||||
subsystem: Subsystem::Network,
|
||||
severity: Severity::Critical,
|
||||
root_cause: "В таблице маршрутизации ядра нет шлюза 0.0.0.0. Пакеты во внешнюю сеть не могут быть смаршрутизированы.".to_string(),
|
||||
explanation: "Сетевой адаптер не получил адрес шлюза по DHCP или интерфейс отключен.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Проверка сетевых интерфейсов и получение аренды DHCP".to_string(),
|
||||
command: "ip route show && ip link".to_string(),
|
||||
explanation: "Выводит текущую таблицу маршрутизации и состояние линков.".to_string(),
|
||||
}),
|
||||
raw_log: "No default gateway (00000000) found in /proc/net/route".to_string(),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if let Some(ref conflict) = data.network_deep_scan.firewall_conflict {
|
||||
network_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "net-firewall-conflict".to_string(),
|
||||
title: "Конфликт нескольких брандмауэров (Firewall Conflict)".to_string(),
|
||||
subsystem: Subsystem::Network,
|
||||
severity: Severity::Warning,
|
||||
root_cause: conflict.clone(),
|
||||
explanation: "Одновременная работа нескольких фаерволов (например, UFW и Firewalld) приводит к гонкам в nftables/iptables и внезапным обрывам соединений.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Отключение конфликтующей службы firewalld".to_string(),
|
||||
command: "sudo systemctl disable --now firewalld".to_string(),
|
||||
explanation: "Оставляет активным только один брандмауэр.".to_string(),
|
||||
}),
|
||||
raw_log: conflict.clone(),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
if !data.network_deep_scan.wifi_drop_events.is_empty() {
|
||||
network_issue_count += 1;
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "net-wifi-drop".to_string(),
|
||||
title: format!("Обнаружены сбросы Wi-Fi сессий (событий: {})", data.network_deep_scan.wifi_drop_events.len()),
|
||||
subsystem: Subsystem::Network,
|
||||
severity: Severity::Warning,
|
||||
root_cause: "Беспроводной адаптер зафиксировал деаутентификацию или потерю маяков точки доступа (beacon loss).".to_string(),
|
||||
explanation: "Возможен слабый сигнал, помехи на частоте или агрессивный режим энергосбережения Wi-Fi адаптера.".to_string(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: "Просмотр детальных логов Wi-Fi демона".to_string(),
|
||||
command: "journalctl -u wpa_supplicant -u iwd -e".to_string(),
|
||||
explanation: "Показывает причины деаутентификации Wi-Fi модуля.".to_string(),
|
||||
}),
|
||||
raw_log: data.network_deep_scan.wifi_drop_events.join("\n"),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Если проблем нет вообще, сформируем Info
|
||||
if issues.is_empty() {
|
||||
issues.push(DiagnosticIssue {
|
||||
id: "ok-1".to_string(),
|
||||
title: format!("Все подсистемы {} ({}) функционируют стабильно", data.os_name, data.init_name),
|
||||
subsystem: Subsystem::Systemd,
|
||||
severity: Severity::Info,
|
||||
root_cause: "Сбоев служб, ошибок ядра dmesg, переполнения дисков и превышения PSI не обнаружено.".to_string(),
|
||||
explanation: format!("Глубокий аудит проверил супервизор {}, кольцевой буфер ядра, файловые системы и лимиты ОС. Система в норме.", data.init_name),
|
||||
fix: None,
|
||||
raw_log: format!("OS: {}, Init: {}, Root: {}. All checks HEALTHY.", data.os_name, data.init_name, data.is_root),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 7. Анализ аварийных сбоев (Crash Dumps)
|
||||
let mut crashes_status = "Healthy".to_string();
|
||||
let mut crashes_issue_count = 0;
|
||||
|
||||
for (idx, crash) in data.crashes.iter().enumerate() {
|
||||
crashes_issue_count += 1;
|
||||
crashes_status = "Warning".to_string();
|
||||
issues.push(DiagnosticIssue {
|
||||
id: format!("crash-{}", idx + 1),
|
||||
title: format!("Аварийный сбой процесса '{}' ({})", crash.process_name, crash.signal_or_code),
|
||||
subsystem: Subsystem::Crashes,
|
||||
severity: if crash.signal_or_code.contains("SIGSEGV") || crash.signal_or_code.contains("Access Violation") {
|
||||
Severity::Critical
|
||||
} else {
|
||||
Severity::Warning
|
||||
},
|
||||
root_cause: format!("Процесс завершился аварийно с кодом/сигналом {}.", crash.signal_or_code),
|
||||
explanation: crash.stack_trace_snippet.clone(),
|
||||
fix: crash.dump_path.as_ref().map(|p| FixSuggestion {
|
||||
title: format!("Анализ дампа {}", crash.process_name),
|
||||
command: format!("gdb {} {} || coredumpctl gdb {}", crash.process_name, p, crash.pid.unwrap_or(0)),
|
||||
explanation: "Запуск отладчика для исследования стека вызовов и локализации сбойной инструкции.".to_string(),
|
||||
}),
|
||||
raw_log: crash.stack_trace_snippet.clone(),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
// 8. Анализ безопасности и открытых портов
|
||||
let mut sec_status = "Healthy".to_string();
|
||||
let mut sec_issue_count = 0;
|
||||
|
||||
for (idx, port) in data.security.exposed_ports.iter().filter(|p| p.is_public && p.risk_level != Severity::Info).enumerate() {
|
||||
sec_issue_count += 1;
|
||||
if port.risk_level == Severity::Critical {
|
||||
sec_status = "Critical".to_string();
|
||||
} else if sec_status != "Critical" {
|
||||
sec_status = "Warning".to_string();
|
||||
}
|
||||
issues.push(DiagnosticIssue {
|
||||
id: format!("sec-port-{}", idx + 1),
|
||||
title: format!("Опасный порт {}: {} слушает на 0.0.0.0", port.port, port.process_name),
|
||||
subsystem: Subsystem::Security,
|
||||
severity: port.risk_level.clone(),
|
||||
root_cause: format!("Служба {} привязана к публичному адресу {} без сетевой изоляции.", port.process_name, port.local_address),
|
||||
explanation: port.recommendation.clone(),
|
||||
fix: Some(FixSuggestion {
|
||||
title: format!("Изоляция порта {}", port.port),
|
||||
command: format!("sudo ufw deny {}/{} || iptables -A INPUT -p {} --dport {} -j DROP", port.port, port.protocol.to_lowercase(), port.protocol.to_lowercase(), port.port),
|
||||
explanation: "Блокировка внешних подключений к порту через системный брандмауэр.".to_string(),
|
||||
}),
|
||||
raw_log: format!("{} {} pid={:?}", port.protocol, port.local_address, port.pid),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
for (idx, suid) in data.security.suid_anomalies.iter().enumerate() {
|
||||
sec_issue_count += 1;
|
||||
sec_status = "Critical".to_string();
|
||||
issues.push(DiagnosticIssue {
|
||||
id: format!("sec-suid-{}", idx + 1),
|
||||
title: format!("Подозрительный SUID файл: {}", suid.path),
|
||||
subsystem: Subsystem::Security,
|
||||
severity: Severity::Critical,
|
||||
root_cause: suid.risk_reason.clone(),
|
||||
explanation: format!("Файл {} имеет права {} и установлен бит SUID/SGID.", suid.path, suid.permissions),
|
||||
fix: Some(FixSuggestion {
|
||||
title: format!("Снятие SUID-бита с {}", suid.path),
|
||||
command: format!("sudo chmod -s {}", suid.path),
|
||||
explanation: "Удаление флага setuid/setgid для предотвращения эскалации привилегий.".to_string(),
|
||||
}),
|
||||
raw_log: format!("path={} perms={}", suid.path, suid.permissions),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
|
||||
for check in &data.security.hardening_checks {
|
||||
if !check.is_secure && check.remediation.is_some() {
|
||||
sec_issue_count += 1;
|
||||
if sec_status == "Healthy" {
|
||||
sec_status = "Warning".to_string();
|
||||
}
|
||||
issues.push(DiagnosticIssue {
|
||||
id: format!("sec-check-{}", check.name.chars().filter(|c| c.is_ascii_alphanumeric()).take(8).collect::<String>()),
|
||||
title: format!("{}: {}", check.name, check.status),
|
||||
subsystem: Subsystem::Security,
|
||||
severity: Severity::Warning,
|
||||
root_cause: check.description.clone(),
|
||||
explanation: format!("Параметр безопасности '{}' в состоянии '{}'.", check.name, check.status),
|
||||
fix: check.remediation.as_ref().map(|rem| FixSuggestion {
|
||||
title: format!("Усиление защиты: {}", check.name),
|
||||
command: rem.clone(),
|
||||
explanation: "Применение рекомендуемого безопасного значения конфигурации.".to_string(),
|
||||
}),
|
||||
raw_log: format!("{}: {}", check.name, check.status),
|
||||
sanitized: true,
|
||||
permission_denied: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
let statuses = vec![
|
||||
SubsystemStatus {
|
||||
name: Subsystem::Systemd,
|
||||
status: init_status,
|
||||
issue_count: init_issue_count,
|
||||
details: format!("{}: сбойных служб — {}", data.init_name, data.failed_services.len()),
|
||||
},
|
||||
SubsystemStatus {
|
||||
name: Subsystem::Kernel,
|
||||
status: kernel_status,
|
||||
issue_count: kernel_issue_count,
|
||||
details: if data.kernel_scan.tainted_value > 0 {
|
||||
format!("Tainted: {}", data.kernel_scan.tainted_flags.join(", "))
|
||||
} else if data.hardware_scan.total_throttle_events > 0 {
|
||||
format!("Thermal throttled: {} раз", data.hardware_scan.total_throttle_events)
|
||||
} else {
|
||||
"Буфер ядра чист, tainted=0".to_string()
|
||||
},
|
||||
},
|
||||
SubsystemStatus {
|
||||
name: Subsystem::Storage,
|
||||
status: storage_status,
|
||||
issue_count: storage_issue_count,
|
||||
details: format!("Проверено точек монтирования: {}", data.storage_mounts.len()),
|
||||
},
|
||||
SubsystemStatus {
|
||||
name: Subsystem::Network,
|
||||
status: network_status,
|
||||
issue_count: network_issue_count,
|
||||
details: if let Some(ms) = data.network_deep_scan.dns_latency_ms {
|
||||
format!("DNS: {} ms | Шлюз: {}", ms, data.network_deep_scan.default_gateway_ip.as_deref().unwrap_or("нет"))
|
||||
} else if data.network_deep_scan.has_default_gateway {
|
||||
format!("Шлюз: {}", data.network_deep_scan.default_gateway_ip.as_deref().unwrap_or("OK"))
|
||||
} else {
|
||||
"Маршрут по умолчанию отсутствует".to_string()
|
||||
},
|
||||
},
|
||||
SubsystemStatus {
|
||||
name: Subsystem::Security,
|
||||
status: sec_status,
|
||||
issue_count: sec_issue_count,
|
||||
details: format!("Защищенность: {}% | Открытых портов: {}", data.security.security_score, data.security.exposed_ports.len()),
|
||||
},
|
||||
SubsystemStatus {
|
||||
name: Subsystem::Crashes,
|
||||
status: crashes_status,
|
||||
issue_count: crashes_issue_count,
|
||||
details: format!("Зафиксировано сбоев: {}", data.crashes.len()),
|
||||
},
|
||||
SubsystemStatus {
|
||||
name: Subsystem::Audio,
|
||||
status: "Healthy".to_string(),
|
||||
issue_count: 0,
|
||||
details: "Аудио подсистема в норме".to_string(),
|
||||
},
|
||||
];
|
||||
|
||||
let summary = format!(
|
||||
"Аудит {} ({}): инцидентов — {}, сбоев — {}, безопасность — {}%, root: {}.",
|
||||
data.os_name,
|
||||
data.init_name,
|
||||
issues.len(),
|
||||
data.crashes.len(),
|
||||
data.security.security_score,
|
||||
if data.is_root { "да" } else { "нет" }
|
||||
);
|
||||
|
||||
(issues, statuses, summary)
|
||||
}
|
||||
|
||||
pub async fn run_scan() -> Result<SystemReport, String> {
|
||||
run_scan_with_mode(crate::auth::AuthMode::Tui).await
|
||||
}
|
||||
|
||||
pub async fn run_scan_with_mode(mode: crate::auth::AuthMode) -> Result<SystemReport, String> {
|
||||
let raw_data = tokio::task::spawn_blocking(move || UnifiedRawData::collect(Some(mode)))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
let (prompt_preview, _) = LlmDiagnosticPrompt::build(&raw_data);
|
||||
let llm_res = try_query_llm(&prompt_preview).await;
|
||||
let llm_analyzed = llm_res.is_some();
|
||||
|
||||
let (issues, subsystems, summary) = heuristic_ai_analyze(&raw_data);
|
||||
let timestamp = chrono::Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
|
||||
|
||||
Ok(SystemReport {
|
||||
timestamp,
|
||||
os_family: raw_data.os_name,
|
||||
init_system: raw_data.init_name,
|
||||
is_root: raw_data.is_root,
|
||||
subsystems,
|
||||
issues,
|
||||
failed_services: raw_data.failed_services,
|
||||
hardware: raw_data.hardware_info,
|
||||
crashes: raw_data.crashes,
|
||||
security: raw_data.security,
|
||||
llm_analyzed,
|
||||
summary,
|
||||
prompt_preview,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
use std::io::Write;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::sync::RwLock;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum AuthMode {
|
||||
Tui,
|
||||
Gui,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum PrivilegeStatus {
|
||||
AlreadyRoot,
|
||||
Elevated,
|
||||
Unprivileged { reason: String },
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
static CACHED_PRIV_STATUS: RwLock<Option<PrivilegeStatus>> = RwLock::new(None);
|
||||
|
||||
pub struct PrivilegeManager;
|
||||
|
||||
impl PrivilegeManager {
|
||||
/// Проверяет, запущен ли текущий процесс с UID 0 (root)
|
||||
pub fn is_current_user_root() -> bool {
|
||||
#[cfg(unix)]
|
||||
{
|
||||
unsafe { libc::geteuid() == 0 }
|
||||
}
|
||||
#[cfg(not(unix))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Возвращает статус привилегий или запрашивает их однократно за сессию
|
||||
pub fn ensure_privileges(mode: AuthMode) -> PrivilegeStatus {
|
||||
#[cfg(test)]
|
||||
{
|
||||
let _ = mode;
|
||||
if Self::is_current_user_root() {
|
||||
return PrivilegeStatus::AlreadyRoot;
|
||||
}
|
||||
if let Ok(status) = Command::new("sudo")
|
||||
.args(["-n", "true"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
{
|
||||
if status.success() {
|
||||
return PrivilegeStatus::Elevated;
|
||||
}
|
||||
}
|
||||
return PrivilegeStatus::Unprivileged {
|
||||
reason: "Non-interactive test mode".to_string(),
|
||||
};
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
{
|
||||
if let Ok(guard) = CACHED_PRIV_STATUS.read() {
|
||||
if let Some(ref status) = *guard {
|
||||
return status.clone();
|
||||
}
|
||||
}
|
||||
|
||||
let status = Self::request_privileges(mode);
|
||||
|
||||
if let Ok(mut guard) = CACHED_PRIV_STATUS.write() {
|
||||
*guard = Some(status.clone());
|
||||
}
|
||||
|
||||
status
|
||||
}
|
||||
}
|
||||
|
||||
/// Однократный запрос повышения привилегий:
|
||||
/// - Если уже root, не запрашивает повторно.
|
||||
/// - В TUI-режиме: интерактивный `sudo -v` с кэшированием временного токена sudoers.
|
||||
/// - В GUI-режиме: графический диалог (Zenity / Polkit pkexec).
|
||||
/// - При отказе или отмене возвращает `PrivilegeStatus::Unprivileged`.
|
||||
pub fn request_privileges(mode: AuthMode) -> PrivilegeStatus {
|
||||
if Self::is_current_user_root() {
|
||||
return PrivilegeStatus::AlreadyRoot;
|
||||
}
|
||||
|
||||
// Проверяем, есть ли уже действующий кэш sudo без ввода пароля (sudo -n true)
|
||||
if let Ok(status) = Command::new("sudo")
|
||||
.args(["-n", "true"])
|
||||
.stdout(std::process::Stdio::null())
|
||||
.stderr(std::process::Stdio::null())
|
||||
.status()
|
||||
{
|
||||
if status.success() {
|
||||
return PrivilegeStatus::Elevated;
|
||||
}
|
||||
}
|
||||
|
||||
match mode {
|
||||
AuthMode::Tui => {
|
||||
println!("\n\x1b[1;36m[diagd]\x1b[0m Для полного аудита ядра (dmesg) и системных журналов требуются права суперпользователя.");
|
||||
println!("\x1b[1;33m[diagd]\x1b[0m Запрос авторизации sudo:");
|
||||
|
||||
match Command::new("sudo").arg("-v").status() {
|
||||
Ok(status) if status.success() => {
|
||||
println!("\x1b[1;32m[diagd]\x1b[0m Привилегии суперпользователя успешно получены.\n");
|
||||
PrivilegeStatus::Elevated
|
||||
}
|
||||
Ok(_) => {
|
||||
println!("\x1b[1;33m[diagd]\x1b[0m Пароль не введен или отклонен. Сканирование продолжится в unprivileged-режиме.\n");
|
||||
PrivilegeStatus::Unprivileged {
|
||||
reason: "Пользователь отменил ввод пароля sudo".to_string(),
|
||||
}
|
||||
}
|
||||
Err(e) => PrivilegeStatus::Unprivileged {
|
||||
reason: format!("Утилита sudo недоступна: {}", e),
|
||||
},
|
||||
}
|
||||
}
|
||||
AuthMode::Gui => {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
if let Ok(status) = Command::new("osascript")
|
||||
.args(["-e", "do shell script \"sudo -v\" with administrator privileges"])
|
||||
.status()
|
||||
{
|
||||
if status.success() {
|
||||
return PrivilegeStatus::Elevated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
let ps_check = "([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)";
|
||||
if let Ok(output) = Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", ps_check]).output() {
|
||||
if String::from_utf8_lossy(&output.stdout).trim() == "True" {
|
||||
return PrivilegeStatus::Elevated;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// В Linux GUI пробуем графический ввод через zenity или pkexec
|
||||
if let Ok(output) = Command::new("zenity")
|
||||
.args(["--password", "--title=diagd • Запрос прав суперпользователя"])
|
||||
.output()
|
||||
{
|
||||
if output.status.success() {
|
||||
let pass = String::from_utf8_lossy(&output.stdout);
|
||||
let trimmed_pass = pass.trim_end_matches(&['\r', '\n'][..]);
|
||||
|
||||
if let Ok(mut child) = Command::new("sudo")
|
||||
.args(["-S", "-v"])
|
||||
.stdin(Stdio::piped())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
{
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
let _ = stdin.write_all(format!("{}\n", trimmed_pass).as_bytes());
|
||||
}
|
||||
if let Ok(exit) = child.wait() {
|
||||
if exit.success() {
|
||||
return PrivilegeStatus::Elevated;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: pkexec
|
||||
match Command::new("pkexec").args(["true"]).status() {
|
||||
Ok(status) if status.success() => PrivilegeStatus::Elevated,
|
||||
Ok(_) => PrivilegeStatus::Unprivileged {
|
||||
reason: "Авторизация Polkit отклонена пользователем".to_string(),
|
||||
},
|
||||
Err(e) => PrivilegeStatus::Unprivileged {
|
||||
reason: format!("Polkit (pkexec) недоступен: {}", e),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Выполняет команду с правами суперпользователя (через sudo -n, если доступно),
|
||||
/// либо делает откат к обычному вызову
|
||||
pub fn exec_privileged(cmd: &str, args: &[&str]) -> std::io::Result<std::process::Output> {
|
||||
if Self::is_current_user_root() {
|
||||
return Command::new(cmd).args(args).output();
|
||||
}
|
||||
|
||||
// Если активен sudo кэш, выполняем через sudo -n
|
||||
if let Ok(privileged_cmd) = Command::new("sudo")
|
||||
.arg("-n")
|
||||
.arg(cmd)
|
||||
.args(args)
|
||||
.output()
|
||||
{
|
||||
if privileged_cmd.status.success() {
|
||||
return Ok(privileged_cmd);
|
||||
} else {
|
||||
let err = String::from_utf8_lossy(&privileged_cmd.stderr);
|
||||
// Если sudo -n отклонен, пробуем обычный вызов
|
||||
if !err.contains("a password is required") {
|
||||
return Ok(privileged_cmd);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Обычный вызов без sudo
|
||||
Command::new(cmd).args(args).output()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_privilege_check() {
|
||||
let is_root = PrivilegeManager::is_current_user_root();
|
||||
assert_eq!(is_root, is_root);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,466 @@
|
||||
use crate::models::{Severity, SystemReport};
|
||||
use flate2::write::GzEncoder;
|
||||
use flate2::Compression;
|
||||
use std::fs::File;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct BundleExporter;
|
||||
|
||||
impl BundleExporter {
|
||||
/// Экспортирует полный архив Debug Bundle:
|
||||
/// - `report.html` (интерактивный dark HTML)
|
||||
/// - `report.md` (Markdown для баг-трекера)
|
||||
/// - `system_report.json` (структурированный дамп)
|
||||
/// Упаковывает всё в tar.gz и возвращает путь к файлу архива.
|
||||
pub fn export_bundle(report: &SystemReport) -> Result<String, String> {
|
||||
let timestamp_safe = report.timestamp.replace([':', ' '], "-");
|
||||
let filename = format!("diagd-bundle-{}.tar.gz", timestamp_safe);
|
||||
|
||||
// Определяем каталог для сохранения: $HOME или /tmp
|
||||
let output_dir = std::env::var("HOME")
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|_| std::env::temp_dir());
|
||||
|
||||
let bundle_path = output_dir.join(&filename);
|
||||
|
||||
let tar_gz_file = File::create(&bundle_path)
|
||||
.map_err(|e| format!("Не удалось создать файл {}: {}", bundle_path.display(), e))?;
|
||||
|
||||
let enc = GzEncoder::new(tar_gz_file, Compression::default());
|
||||
let mut tar = tar::Builder::new(enc);
|
||||
|
||||
// 1. Создаем HTML отчет
|
||||
let html_content = Self::generate_html(report);
|
||||
let mut html_header = tar::Header::new_gnu();
|
||||
html_header.set_size(html_content.len() as u64);
|
||||
html_header.set_mode(0o644);
|
||||
html_header.set_cksum();
|
||||
tar.append_data(&mut html_header, "report.html", html_content.as_bytes())
|
||||
.map_err(|e| format!("Ошибка добавления report.html в архив: {}", e))?;
|
||||
|
||||
// 2. Создаем Markdown отчет
|
||||
let md_content = Self::generate_markdown(report);
|
||||
let mut md_header = tar::Header::new_gnu();
|
||||
md_header.set_size(md_content.len() as u64);
|
||||
md_header.set_mode(0o644);
|
||||
md_header.set_cksum();
|
||||
tar.append_data(&mut md_header, "report.md", md_content.as_bytes())
|
||||
.map_err(|e| format!("Ошибка добавления report.md в архив: {}", e))?;
|
||||
|
||||
// 3. Создаем system_report.json
|
||||
let json_content = serde_json::to_string_pretty(report).unwrap_or_default();
|
||||
let mut json_header = tar::Header::new_gnu();
|
||||
json_header.set_size(json_content.len() as u64);
|
||||
json_header.set_mode(0o644);
|
||||
json_header.set_cksum();
|
||||
tar.append_data(&mut json_header, "system_report.json", json_content.as_bytes())
|
||||
.map_err(|e| format!("Ошибка добавления system_report.json в архив: {}", e))?;
|
||||
|
||||
tar.finish()
|
||||
.map_err(|e| format!("Ошибка финализации архива tar.gz: {}", e))?;
|
||||
|
||||
// Также сохраняем отдельную копию HTML для моментального открытия в браузере
|
||||
let standalone_html = output_dir.join(format!("diagd-report-{}.html", timestamp_safe));
|
||||
if let Ok(mut f) = File::create(&standalone_html) {
|
||||
let _ = f.write_all(html_content.as_bytes());
|
||||
}
|
||||
|
||||
Ok(bundle_path.to_string_lossy().to_string())
|
||||
}
|
||||
|
||||
pub fn generate_markdown(report: &SystemReport) -> String {
|
||||
let mut md = String::new();
|
||||
md.push_str(&format!("# Отчет диагностики diagd • {}\n\n", report.timestamp));
|
||||
md.push_str(&format!("- **ОС**: {}\n", report.os_family));
|
||||
md.push_str(&format!("- **Init System**: {}\n", report.init_system));
|
||||
md.push_str(&format!("- **Root**: {}\n", if report.is_root { "Да" } else { "Нет (unprivileged)" }));
|
||||
md.push_str(&format!("- **Сводка**: {}\n\n", report.summary));
|
||||
|
||||
md.push_str("## Статус подсистем\n\n");
|
||||
md.push_str("| Подсистема | Статус | Ошибок | Детали |\n");
|
||||
md.push_str("|------------|--------|--------|--------|\n");
|
||||
for sub in &report.subsystems {
|
||||
md.push_str(&format!(
|
||||
"| {:?} | {} | {} | {} |\n",
|
||||
sub.name, sub.status, sub.issue_count, sub.details
|
||||
));
|
||||
}
|
||||
|
||||
md.push_str("\n## Обнаруженные проблемы\n\n");
|
||||
for issue in &report.issues {
|
||||
let badge = match issue.severity {
|
||||
Severity::Critical => "🔴 CRITICAL",
|
||||
Severity::Warning => "🟡 WARNING",
|
||||
Severity::Info => "🔵 INFO",
|
||||
};
|
||||
md.push_str(&format!("### {} {}\n\n", badge, issue.title));
|
||||
md.push_str(&format!("- **Подсистема**: `{:?}`\n", issue.subsystem));
|
||||
md.push_str(&format!("- **Корень проблемы**: {}\n", issue.root_cause));
|
||||
md.push_str(&format!("- **Объяснение**: {}\n", issue.explanation));
|
||||
|
||||
if let Some(ref fix) = issue.fix {
|
||||
md.push_str(&format!("\n**Рекомендуемое решение:** {}\n", fix.title));
|
||||
md.push_str("```bash\n");
|
||||
md.push_str(&fix.command);
|
||||
md.push_str("\n```\n");
|
||||
}
|
||||
|
||||
if !issue.raw_log.trim().is_empty() {
|
||||
md.push_str("\n<details><summary>Сырой лог</summary>\n\n```text\n");
|
||||
md.push_str(&issue.raw_log);
|
||||
md.push_str("\n```\n</details>\n");
|
||||
}
|
||||
md.push_str("\n---\n\n");
|
||||
}
|
||||
|
||||
let hw = &report.hardware;
|
||||
md.push_str("## Спецификация оборудования (Hardware Profile)\n\n");
|
||||
md.push_str(&format!("- **Процессор (CPU)**: {} ({} ядер / {} потоков, arch: {})\n", hw.cpu.model_name, hw.cpu.physical_cores, hw.cpu.logical_threads, hw.cpu.architecture));
|
||||
if let Some(freq) = hw.cpu.current_frequency_mhz {
|
||||
md.push_str(&format!(" - Частота: {:.0} MHz | Кэш: {}\n", freq, hw.cpu.l1_cache));
|
||||
}
|
||||
if let Some(ref virt) = hw.cpu.virtualization {
|
||||
md.push_str(&format!(" - Виртуализация: {}\n", virt));
|
||||
}
|
||||
md.push_str(&format!("- **Материнская плата**: {} {} (BIOS: {} {})\n", hw.motherboard.vendor, hw.motherboard.product_name, hw.motherboard.bios_vendor, hw.motherboard.bios_version));
|
||||
let ram_total_gb = hw.memory.total_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let ram_used_gb = hw.memory.used_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
md.push_str(&format!("- **Память (RAM)**: Всего: {:.1} GB | Занято: {:.1} GB | Ошибки ECC (CE: {}, UE: {})\n", ram_total_gb, ram_used_gb, hw.memory.edac_ce_count, hw.memory.edac_ue_count));
|
||||
|
||||
if !hw.gpus.is_empty() {
|
||||
md.push_str("- **Графика (GPU)**:\n");
|
||||
for gpu in &hw.gpus {
|
||||
md.push_str(&format!(" - {} (драйвер: {}, слот: {})\n", gpu.model, gpu.driver, gpu.pci_slot));
|
||||
}
|
||||
}
|
||||
|
||||
if !hw.disks.is_empty() {
|
||||
md.push_str("- **Накопители (Disks)**:\n");
|
||||
for disk in &hw.disks {
|
||||
let size_gb = disk.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let smart_str = if !disk.smart_status.is_empty() { format!(" | SMART: {}", disk.smart_status) } else { "".to_string() };
|
||||
let health_str = disk.health_percent.map(|h| format!(" | Здоровье: {}%", h)).unwrap_or_default();
|
||||
let wear_str = disk.wear_percent.map(|w| format!(" | Износ: {}%", w)).unwrap_or_default();
|
||||
let temp_str = disk.temperature_c.map(|t| format!(" | Темп: {}°C", t)).unwrap_or_default();
|
||||
let pwr_str = disk.power_on_hours.map(|p| format!(" | Наработка: {} ч.", p)).unwrap_or_default();
|
||||
let sn_str = if !disk.serial.is_empty() { format!(" | S/N: {}", disk.serial) } else { "".to_string() };
|
||||
md.push_str(&format!(" - `{}` — {} ({:.1} GB, {}{}{}{}{}{}{})\n", disk.name, disk.model, size_gb, disk.disk_type, smart_str, health_str, wear_str, temp_str, pwr_str, sn_str));
|
||||
for part in &disk.partitions {
|
||||
let part_gb = part.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let mount = part.mount_point.as_deref().unwrap_or("не смонтирован");
|
||||
md.push_str(&format!(" - `{}` ({:.1} GB, {}) -> {}\n", part.name, part_gb, part.fs_type, mount));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(ref batt) = hw.battery {
|
||||
md.push_str(&format!("- **Батарея**: {} (емкость: {}%, здоровье: {:.1}%, циклов: {}, статус: {})\n", batt.name, batt.capacity_percent, batt.health_percent, batt.cycle_count, batt.status));
|
||||
}
|
||||
|
||||
md.push_str(&format!("- **Температура / Охлаждение**: Макс. темп CPU: {:.1}°C | Троттлинг: {} событий\n\n", hw.thermals.max_cpu_temp_c, hw.thermals.total_throttle_events));
|
||||
|
||||
if !report.crashes.is_empty() {
|
||||
md.push_str("## Зафиксированные падения и аварийные дампы (Crashes)\n\n");
|
||||
md.push_str("| Время | Процесс | Сигнал / Код | Сбойный модуль | Дамп |\n");
|
||||
md.push_str("|---|---|---|---|---|\n");
|
||||
for c in &report.crashes {
|
||||
md.push_str(&format!(
|
||||
"| {} | `{}` | `{}` | {} | {} |\n",
|
||||
c.timestamp,
|
||||
c.process_name,
|
||||
c.signal_or_code,
|
||||
c.fault_module.as_deref().unwrap_or("—"),
|
||||
c.dump_path.as_deref().unwrap_or("—")
|
||||
));
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
|
||||
md.push_str(&format!("## Аудит безопасности (Оценка защищенности: {}%)\n\n", report.security.security_score));
|
||||
if !report.security.exposed_ports.is_empty() {
|
||||
md.push_str("### Открытые порты и сетевые службы\n\n");
|
||||
md.push_str("| Протокол | Адрес:Порт | Процесс | Доступность | Уровень риска |\n");
|
||||
md.push_str("|---|---|---|---|---|\n");
|
||||
for p in &report.security.exposed_ports {
|
||||
let pub_badge = if p.is_public { "🌐 Публичный" } else { "🔒 Локальный" };
|
||||
let risk_badge = match p.risk_level {
|
||||
Severity::Critical => "🔴 КРИТИЧЕСКИЙ",
|
||||
Severity::Warning => "🟡 ВНИМАНИЕ",
|
||||
Severity::Info => "🔵 ИНФО",
|
||||
};
|
||||
md.push_str(&format!(
|
||||
"| {} | `{}` | `{}` | {} | {} |\n",
|
||||
p.protocol, p.local_address, p.process_name, pub_badge, risk_badge
|
||||
));
|
||||
}
|
||||
md.push_str("\n");
|
||||
}
|
||||
|
||||
md
|
||||
}
|
||||
|
||||
pub fn generate_html(report: &SystemReport) -> String {
|
||||
let mut html = String::new();
|
||||
html.push_str(r#"<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>diagd • Отчет диагностики системы</title>
|
||||
<style>
|
||||
:root {
|
||||
--bg: #0d1117;
|
||||
--card: #161b22;
|
||||
--border: #30363d;
|
||||
--text: #c9d1d9;
|
||||
--text-bright: #f0f6fc;
|
||||
--accent: #58a6ff;
|
||||
--green: #3fb950;
|
||||
--red: #f85149;
|
||||
--yellow: #d29922;
|
||||
}
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
|
||||
background-color: var(--bg);
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
padding: 30px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.container { max-width: 1050px; margin: 0 auto; }
|
||||
.header {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
padding: 24px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 24px;
|
||||
}
|
||||
h1 { margin: 0 0 8px 0; color: var(--text-bright); font-size: 24px; }
|
||||
.meta { color: #8b949e; font-size: 14px; margin-bottom: 12px; }
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.sub-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 14px;
|
||||
}
|
||||
.sub-name { font-weight: bold; color: var(--text-bright); }
|
||||
.issue-card {
|
||||
background: var(--card);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 6px;
|
||||
font-weight: bold;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.5px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.badge-crit { background: rgba(248, 81, 73, 0.2); color: var(--red); border: 1px solid var(--red); }
|
||||
.badge-warn { background: rgba(210, 153, 34, 0.2); color: var(--yellow); border: 1px solid var(--yellow); }
|
||||
.badge-info { background: rgba(88, 166, 255, 0.2); color: var(--accent); border: 1px solid var(--accent); }
|
||||
.cmd-box {
|
||||
background: #090d13;
|
||||
border: 1px solid #21262d;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
font-family: monospace;
|
||||
color: #79c0ff;
|
||||
margin-top: 10px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
details { margin-top: 12px; }
|
||||
pre {
|
||||
background: #090d13;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
color: #8b949e;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>diagd • Linux / BSD AI Diagnostic Report</h1>
|
||||
<div class="meta">
|
||||
Дата: <b>"#);
|
||||
html.push_str(&report.timestamp);
|
||||
html.push_str(r#"</b> | ОС: <b>"#);
|
||||
html.push_str(&report.os_family);
|
||||
html.push_str(r#"</b> | Init: <b>"#);
|
||||
html.push_str(&report.init_system);
|
||||
html.push_str(r#"</b> | Права root: <b>"#);
|
||||
html.push_str(if report.is_root { "Да" } else { "Нет (unprivileged)" });
|
||||
html.push_str(r#"</b>
|
||||
</div>
|
||||
<div>"#);
|
||||
html.push_str(&report.summary);
|
||||
html.push_str(r#"</div>
|
||||
</div>
|
||||
|
||||
<h2>Состояние подсистем</h2>
|
||||
<div class="grid">"#);
|
||||
|
||||
for sub in &report.subsystems {
|
||||
let color_class = if sub.status == "Healthy" { "color: var(--green);" } else { "color: var(--red);" };
|
||||
html.push_str(&format!(
|
||||
r#"<div class="sub-card">
|
||||
<div class="sub-name">{:?}</div>
|
||||
<div style="{} font-weight: bold; font-size: 12px;">{}</div>
|
||||
<div style="font-size: 12px; color: #8b949e;">Ошибок: {}</div>
|
||||
</div>"#,
|
||||
sub.name, color_class, sub.status, sub.issue_count
|
||||
));
|
||||
}
|
||||
|
||||
html.push_str(r#"</div>
|
||||
<h2>Обнаруженные инциденты</h2>"#);
|
||||
|
||||
for issue in &report.issues {
|
||||
let (badge_cls, badge_txt) = match issue.severity {
|
||||
Severity::Critical => ("badge-crit", "CRITICAL"),
|
||||
Severity::Warning => ("badge-warn", "WARNING"),
|
||||
Severity::Info => ("badge-info", "INFO"),
|
||||
};
|
||||
|
||||
html.push_str(&format!(
|
||||
r#"<div class="issue-card">
|
||||
<span class="badge {}">{}</span>
|
||||
<span style="font-size: 12px; color: #8b949e;">{:?}</span>
|
||||
<h3 style="color: var(--text-bright); margin: 8px 0;">{}</h3>
|
||||
<div style="margin-bottom: 6px;"><b>Корень проблемы:</b> {}</div>
|
||||
<div style="color: #8b949e; font-size: 13px;">{}</div>"#,
|
||||
badge_cls, badge_txt, issue.subsystem, issue.title, issue.root_cause, issue.explanation
|
||||
));
|
||||
|
||||
if let Some(ref fix) = issue.fix {
|
||||
html.push_str(&format!(
|
||||
r#"<div class="cmd-box">
|
||||
<div style="color: var(--green); font-size: 11px; margin-bottom: 4px;">✓ {}</div>
|
||||
$ {}
|
||||
</div>"#,
|
||||
fix.title, fix.command
|
||||
));
|
||||
}
|
||||
|
||||
if !issue.raw_log.trim().is_empty() {
|
||||
html.push_str(&format!(
|
||||
r#"<details>
|
||||
<summary style="cursor: pointer; color: var(--accent); font-size: 13px;">Показать сырой лог</summary>
|
||||
<pre>{}</pre>
|
||||
</details>"#,
|
||||
issue.raw_log
|
||||
));
|
||||
}
|
||||
|
||||
html.push_str("</div>");
|
||||
}
|
||||
|
||||
let hw = &report.hardware;
|
||||
let ram_total_gb = hw.memory.total_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let ram_used_gb = hw.memory.used_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
|
||||
html.push_str(r#"
|
||||
<h2>Спецификация оборудования (Hardware Profile)</h2>
|
||||
<div class="grid">
|
||||
<div class="sub-card">
|
||||
<div class="sub-name">Процессор (CPU)</div>
|
||||
<div style="font-size: 13px; color: var(--text-bright); margin-top: 4px;">"#);
|
||||
html.push_str(&hw.cpu.model_name);
|
||||
html.push_str(&format!(r#"</div>
|
||||
<div style="font-size: 12px; color: #8b949e; margin-top: 4px;">Ядер: {} | Потоков: {} | Кэш: {}</div>
|
||||
</div>
|
||||
<div class="sub-card">
|
||||
<div class="sub-name">Материнская плата & BIOS</div>
|
||||
<div style="font-size: 13px; color: var(--text-bright); margin-top: 4px;">{} {}</div>
|
||||
<div style="font-size: 12px; color: #8b949e; margin-top: 4px;">BIOS: {} {} ({})</div>
|
||||
</div>
|
||||
<div class="sub-card">
|
||||
<div class="sub-name">Оперативная память (RAM)</div>
|
||||
<div style="font-size: 13px; color: var(--text-bright); margin-top: 4px;">{:.1} GB (занято {:.1} GB)</div>
|
||||
<div style="font-size: 12px; color: #8b949e; margin-top: 4px;">ECC сбои: CE={}, UE={}</div>
|
||||
</div>
|
||||
<div class="sub-card">
|
||||
<div class="sub-name">Термометрия & Батарея</div>
|
||||
<div style="font-size: 13px; color: var(--text-bright); margin-top: 4px;">Макс. темп: {:.1}°C</div>
|
||||
<div style="font-size: 12px; color: #8b949e; margin-top: 4px;">Троттлинг: {} событий</div>
|
||||
</div>
|
||||
</div>
|
||||
"#,
|
||||
hw.cpu.physical_cores, hw.cpu.logical_threads, hw.cpu.l1_cache,
|
||||
hw.motherboard.vendor, hw.motherboard.product_name,
|
||||
hw.motherboard.bios_vendor, hw.motherboard.bios_version, hw.motherboard.bios_date,
|
||||
ram_total_gb, ram_used_gb,
|
||||
hw.memory.edac_ce_count, hw.memory.edac_ue_count,
|
||||
hw.thermals.max_cpu_temp_c,
|
||||
hw.thermals.total_throttle_events
|
||||
));
|
||||
|
||||
html.push_str("<h3>Накопители данных (Disks)</h3>\n<div style=\"display: flex; flex-direction: column; gap: 10px;\">");
|
||||
for disk in &hw.disks {
|
||||
let size_gb = disk.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let health_val = disk.health_percent.unwrap_or(100);
|
||||
let wear_val = disk.wear_percent.unwrap_or(0);
|
||||
let bar_color = if health_val >= 80 { "var(--green)" } else if health_val >= 50 { "var(--warn)" } else { "var(--red)" };
|
||||
|
||||
html.push_str(&format!(
|
||||
r#"<div class="sub-card" style="padding: 12px 16px;">
|
||||
<div style="display: flex; justify-content: space-between; align-items: center;">
|
||||
<div>
|
||||
<b style="color: var(--accent); font-family: monospace;">{}</b>
|
||||
<span style="color: var(--text-bright); margin-left: 8px;">{}</span>
|
||||
<span style="font-size: 11px; background: #21262d; border: 1px solid #30363d; border-radius: 4px; padding: 2px 6px; margin-left: 8px;">{}</span>
|
||||
</div>
|
||||
<div style="font-size: 12px; color: var(--green);">SMART: {}</div>
|
||||
</div>
|
||||
<div style="margin-top: 8px; font-size: 12px; color: #8b949e;">
|
||||
Здоровье: <b>{}%</b> (Износ: {}%) • Емкость: <b>{:.1} GB</b>
|
||||
{} {}
|
||||
</div>
|
||||
<div style="background: #21262d; border-radius: 4px; height: 6px; margin-top: 6px; overflow: hidden;">
|
||||
<div style="background: {}; height: 100%; width: {}%;"></div>
|
||||
</div>
|
||||
"#,
|
||||
disk.name, disk.model, disk.disk_type, disk.smart_status,
|
||||
health_val, wear_val, size_gb,
|
||||
disk.temperature_c.map(|t| format!("• Температура: <b>{}°C</b>", t)).unwrap_or_default(),
|
||||
disk.power_on_hours.map(|p| format!("• Наработка: <b>{} ч.</b>", p)).unwrap_or_default(),
|
||||
bar_color, health_val
|
||||
));
|
||||
|
||||
if !disk.partitions.is_empty() {
|
||||
html.push_str("<div style=\"margin-top: 8px; font-size: 11px; color: #8b949e;\">Разделы: ");
|
||||
for (idx, part) in disk.partitions.iter().enumerate() {
|
||||
let p_gb = part.size_bytes as f64 / (1024.0 * 1024.0 * 1024.0);
|
||||
let mount = part.mount_point.as_deref().unwrap_or("не смонтирован");
|
||||
if idx > 0 { html.push_str(" | "); }
|
||||
html.push_str(&format!("<code>{}</code> ({:.1} GB, {}) ➔ <code>{}</code>", part.name, p_gb, part.fs_type, mount));
|
||||
}
|
||||
html.push_str("</div>");
|
||||
}
|
||||
|
||||
html.push_str("</div>");
|
||||
}
|
||||
html.push_str("</div>");
|
||||
|
||||
html.push_str(r#"
|
||||
</div>
|
||||
</body>
|
||||
</html>"#);
|
||||
|
||||
html
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
pub mod auth;
|
||||
pub mod models;
|
||||
pub mod sanitize;
|
||||
pub mod scanner;
|
||||
pub mod analyst;
|
||||
pub mod export;
|
||||
|
||||
pub use auth::{AuthMode, PrivilegeManager, PrivilegeStatus};
|
||||
pub use models::*;
|
||||
pub use sanitize::DataSanitizer;
|
||||
pub use analyst::{run_scan, run_scan_with_mode};
|
||||
pub use export::BundleExporter;
|
||||
pub use scanner::init::{detect_init_system, InitSystemProvider, ScanError};
|
||||
pub use scanner::live::LiveMetricsScanner;
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_core_deep_scan() {
|
||||
let report = run_scan().await;
|
||||
assert!(report.is_ok());
|
||||
let rep = report.unwrap();
|
||||
println!("=== DISKS SCANNED: {} ===", rep.hardware.disks.len());
|
||||
for d in &rep.hardware.disks {
|
||||
println!("Disk: {} ({}) | SMART: {} | Health: {:?}% | Wear: {:?}% | Temp: {:?}°C | Hours: {:?} | TBW: {:?} TB | Partitions: {}",
|
||||
d.name, d.model, d.smart_status, d.health_percent, d.wear_percent, d.temperature_c, d.power_on_hours, d.total_written_tb, d.partitions.len());
|
||||
for p in &d.partitions {
|
||||
println!(" -> Part: {} ({}) mount: {:?}", p.name, p.fs_type, p.mount_point);
|
||||
}
|
||||
}
|
||||
assert!(!rep.subsystems.is_empty());
|
||||
assert!(!rep.issues.is_empty());
|
||||
assert!(!rep.init_system.is_empty());
|
||||
assert!(!rep.hardware.disks.is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Уровень критичности обнаруженной проблемы
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
pub enum Severity {
|
||||
Critical,
|
||||
Warning,
|
||||
#[default]
|
||||
Info,
|
||||
}
|
||||
|
||||
/// Исследуемая подсистема ОС (Linux/BSD)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum Subsystem {
|
||||
Systemd, // Службы Init (systemd, openrc, runit, etc.)
|
||||
Kernel,
|
||||
Storage,
|
||||
Audio,
|
||||
Network,
|
||||
Security,
|
||||
Crashes,
|
||||
}
|
||||
|
||||
/// Статус состояния подсистемы для сводного дашборда
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct SubsystemStatus {
|
||||
pub name: Subsystem,
|
||||
pub status: String, // "Healthy", "Degraded", "Failed"
|
||||
pub issue_count: u32,
|
||||
pub details: String,
|
||||
}
|
||||
|
||||
/// Предложение по устранению ошибки с готовой CLI-командой
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FixSuggestion {
|
||||
pub title: String,
|
||||
pub command: String,
|
||||
pub explanation: String,
|
||||
}
|
||||
|
||||
/// Информация о сбойной системной службе (в независимости от Init: systemd, OpenRC, runit...)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct FailedService {
|
||||
pub name: String,
|
||||
pub init_system: String,
|
||||
pub state: String,
|
||||
pub description: String,
|
||||
pub logs: String,
|
||||
pub restart_command: String,
|
||||
pub status_command: String,
|
||||
}
|
||||
|
||||
/// Элемент диагностического отчета (проблема)
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct DiagnosticIssue {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub subsystem: Subsystem,
|
||||
pub severity: Severity,
|
||||
pub root_cause: String,
|
||||
pub explanation: String,
|
||||
pub fix: Option<FixSuggestion>,
|
||||
pub raw_log: String,
|
||||
pub sanitized: bool,
|
||||
pub permission_denied: bool,
|
||||
}
|
||||
|
||||
pub type IssueItem = DiagnosticIssue;
|
||||
|
||||
/// Подробный отчет об аппаратном обеспечении системы
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct HardwareInfo {
|
||||
pub cpu: CpuInfo,
|
||||
pub memory: MemoryInfo,
|
||||
pub motherboard: MotherboardInfo,
|
||||
pub gpus: Vec<GpuInfo>,
|
||||
pub disks: Vec<DiskInfo>,
|
||||
pub network_adapters: Vec<NetworkAdapterInfo>,
|
||||
pub battery: Option<BatteryInfo>,
|
||||
pub thermals: ThermalsInfo,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct CpuInfo {
|
||||
pub model_name: String,
|
||||
pub architecture: String,
|
||||
pub physical_cores: usize,
|
||||
pub logical_threads: usize,
|
||||
pub base_frequency_mhz: Option<f32>,
|
||||
pub current_frequency_mhz: Option<f32>,
|
||||
pub l1_cache: String,
|
||||
pub l2_cache: String,
|
||||
pub l3_cache: String,
|
||||
pub virtualization: Option<String>,
|
||||
pub flags: Vec<String>,
|
||||
pub microcode: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct MemoryInfo {
|
||||
pub total_bytes: u64,
|
||||
pub available_bytes: u64,
|
||||
pub used_bytes: u64,
|
||||
pub swap_total_bytes: u64,
|
||||
pub swap_used_bytes: u64,
|
||||
pub edac_ce_count: u64,
|
||||
pub edac_ue_count: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct MotherboardInfo {
|
||||
pub vendor: String,
|
||||
pub product_name: String,
|
||||
pub version: String,
|
||||
pub bios_vendor: String,
|
||||
pub bios_version: String,
|
||||
pub bios_date: String,
|
||||
pub chassis_type: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct GpuInfo {
|
||||
pub vendor: String,
|
||||
pub model: String,
|
||||
pub driver: String,
|
||||
pub pci_slot: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct PartitionInfo {
|
||||
pub name: String,
|
||||
pub path: String,
|
||||
pub mount_point: Option<String>,
|
||||
pub fs_type: String,
|
||||
pub size_bytes: u64,
|
||||
pub used_bytes: u64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct DiskInfo {
|
||||
pub name: String,
|
||||
pub model: String,
|
||||
pub serial: String,
|
||||
pub size_bytes: u64,
|
||||
pub disk_type: String,
|
||||
pub rotational_speed_rpm: Option<u32>,
|
||||
pub smart_status: String,
|
||||
pub health_percent: Option<u8>,
|
||||
pub wear_percent: Option<u8>,
|
||||
pub nvme_wear_percent: Option<u8>,
|
||||
pub temperature_c: Option<i32>,
|
||||
pub power_on_hours: Option<u64>,
|
||||
pub power_cycle_count: Option<u64>,
|
||||
pub bad_sectors_count: Option<u64>,
|
||||
pub total_written_tb: Option<f64>,
|
||||
pub partitions: Vec<PartitionInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct NetworkAdapterInfo {
|
||||
pub interface_name: String,
|
||||
pub if_type: String,
|
||||
pub state: String,
|
||||
pub speed_mbps: Option<u32>,
|
||||
pub mac_address: String,
|
||||
pub driver: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct BatteryInfo {
|
||||
pub name: String,
|
||||
pub capacity_percent: u8,
|
||||
pub health_percent: f32,
|
||||
pub cycle_count: u32,
|
||||
pub status: String,
|
||||
pub is_degraded: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ThermalsInfo {
|
||||
pub max_cpu_temp_c: f32,
|
||||
pub thermal_throttled_cores: usize,
|
||||
pub total_throttle_events: u64,
|
||||
pub is_actively_throttling: bool,
|
||||
}
|
||||
|
||||
// --- LIVE METRICS ---
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct LiveMetricsSnapshot {
|
||||
pub cpu_usage_percent: f32,
|
||||
pub ram_used_bytes: u64,
|
||||
pub ram_total_bytes: u64,
|
||||
pub swap_used_bytes: u64,
|
||||
pub swap_total_bytes: u64,
|
||||
pub max_cpu_temp_c: f32,
|
||||
pub is_throttling: bool,
|
||||
pub disk_read_kbps: u64,
|
||||
pub disk_write_kbps: u64,
|
||||
pub net_rx_kbps: u64,
|
||||
pub net_tx_kbps: u64,
|
||||
}
|
||||
|
||||
// --- CRASH DUMPS ---
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct CrashReportInfo {
|
||||
pub timestamp: String,
|
||||
pub process_name: String,
|
||||
pub pid: Option<u32>,
|
||||
pub signal_or_code: String,
|
||||
pub fault_module: Option<String>,
|
||||
pub stack_trace_snippet: String,
|
||||
pub dump_path: Option<String>,
|
||||
}
|
||||
|
||||
// --- SECURITY AUDIT ---
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct ListeningPortInfo {
|
||||
pub protocol: String,
|
||||
pub local_address: String,
|
||||
pub port: u16,
|
||||
pub process_name: String,
|
||||
pub pid: Option<u32>,
|
||||
pub is_public: bool,
|
||||
pub risk_level: Severity,
|
||||
pub recommendation: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct SuidAnomalyInfo {
|
||||
pub path: String,
|
||||
pub owner: String,
|
||||
pub permissions: String,
|
||||
pub risk_reason: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct SecurityCheckItem {
|
||||
pub category: String,
|
||||
pub name: String,
|
||||
pub status: String,
|
||||
pub is_secure: bool,
|
||||
pub description: String,
|
||||
pub remediation: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct SecurityAuditReport {
|
||||
pub exposed_ports: Vec<ListeningPortInfo>,
|
||||
pub suid_anomalies: Vec<SuidAnomalyInfo>,
|
||||
pub hardening_checks: Vec<SecurityCheckItem>,
|
||||
pub security_score: u8,
|
||||
}
|
||||
|
||||
/// Общий итоговый отчет диагностики
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
|
||||
pub struct SystemReport {
|
||||
pub timestamp: String,
|
||||
pub os_family: String, // "Linux", "FreeBSD", "OpenBSD", etc.
|
||||
pub init_system: String, // "systemd", "openrc", "runit", "s6", "dinit", "bsd-rc"
|
||||
pub is_root: bool,
|
||||
pub subsystems: Vec<SubsystemStatus>,
|
||||
pub issues: Vec<IssueItem>,
|
||||
pub failed_services: Vec<FailedService>,
|
||||
pub hardware: HardwareInfo,
|
||||
pub crashes: Vec<CrashReportInfo>,
|
||||
pub security: SecurityAuditReport,
|
||||
pub llm_analyzed: bool,
|
||||
pub summary: String,
|
||||
pub prompt_preview: String,
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
||||
pub struct DataSanitizer;
|
||||
|
||||
static RE_HOME_PATH: OnceLock<Regex> = OnceLock::new();
|
||||
static RE_USR_HOME_PATH: OnceLock<Regex> = OnceLock::new();
|
||||
static RE_IPV4: OnceLock<Regex> = OnceLock::new();
|
||||
static RE_MAC_ADDR: OnceLock<Regex> = OnceLock::new();
|
||||
static RE_AUTH_TOKEN: OnceLock<Regex> = OnceLock::new();
|
||||
static RE_UUID: OnceLock<Regex> = OnceLock::new();
|
||||
|
||||
impl DataSanitizer {
|
||||
/// Очищает системные логи от персональных данных:
|
||||
/// - Домашние пути (/home/<user>/ и /usr/home/<user>/ на BSD -> /home/[USER]/)
|
||||
/// - IPv4 адреса (192.168.x.x, внешние IP)
|
||||
/// - MAC-адреса
|
||||
/// - Токены/секреты/пароли
|
||||
/// - Аппаратные UUID
|
||||
pub fn sanitize(raw: &str) -> String {
|
||||
let home_re = RE_HOME_PATH.get_or_init(|| {
|
||||
Regex::new(r"/home/[a-zA-Z0-9_\-]+").expect("Invalid home regex")
|
||||
});
|
||||
let usr_home_re = RE_USR_HOME_PATH.get_or_init(|| {
|
||||
Regex::new(r"/usr/home/[a-zA-Z0-9_\-]+").expect("Invalid BSD usr home regex")
|
||||
});
|
||||
let ipv4_re = RE_IPV4.get_or_init(|| {
|
||||
Regex::new(r"\b(?:\d{1,3}\.){3}\d{1,3}\b").expect("Invalid IPv4 regex")
|
||||
});
|
||||
let mac_re = RE_MAC_ADDR.get_or_init(|| {
|
||||
Regex::new(r"(?i)\b([0-9a-f]{2}[:-]){5}([0-9a-f]{2})\b").expect("Invalid MAC regex")
|
||||
});
|
||||
let auth_re = RE_AUTH_TOKEN.get_or_init(|| {
|
||||
Regex::new(r"(?i)(bearer|token|password|secret|api[_-]?key)[=:\s]+[A-Za-z0-9_\-\.\/]{8,}").expect("Invalid secret regex")
|
||||
});
|
||||
let uuid_re = RE_UUID.get_or_init(|| {
|
||||
Regex::new(r"\b[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}\b").expect("Invalid UUID regex")
|
||||
});
|
||||
|
||||
let mut clean = home_re.replace_all(raw, "/home/[USER]").to_string();
|
||||
clean = usr_home_re.replace_all(&clean, "/home/[USER]").to_string();
|
||||
clean = auth_re.replace_all(&clean, "$1=[REDACTED_SECRET]").to_string();
|
||||
clean = mac_re.replace_all(&clean, "[MAC_ADDR]").to_string();
|
||||
clean = uuid_re.replace_all(&clean, "[UUID]").to_string();
|
||||
|
||||
clean = ipv4_re.replace_all(&clean, |caps: ®ex::Captures| {
|
||||
let ip = &caps[0];
|
||||
if ip.starts_with("127.") {
|
||||
ip.to_string()
|
||||
} else {
|
||||
"[IP_ADDR]".to_string()
|
||||
}
|
||||
}).to_string();
|
||||
|
||||
clean
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_sanitizer() {
|
||||
let text = "Error for /usr/home/bsduser/file.txt and /home/linuxuser/app.rs at 10.0.0.4 with token=xyzSecret12345";
|
||||
let clean = DataSanitizer::sanitize(text);
|
||||
assert!(!clean.contains("bsduser"));
|
||||
assert!(!clean.contains("linuxuser"));
|
||||
assert!(!clean.contains("10.0.0.4"));
|
||||
assert!(!clean.contains("xyzSecret12345"));
|
||||
assert!(clean.contains("/home/[USER]"));
|
||||
assert!(clean.contains("[IP_ADDR]"));
|
||||
assert!(clean.contains("[REDACTED_SECRET]"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use std::process::Command;
|
||||
|
||||
pub struct BsdRawData {
|
||||
pub failed_services: Vec<String>,
|
||||
pub service_logs: String,
|
||||
pub kernel_logs: String,
|
||||
pub storage_logs: String,
|
||||
pub network_logs: String,
|
||||
pub audio_logs: String,
|
||||
}
|
||||
|
||||
impl BsdRawData {
|
||||
pub fn collect() -> Self {
|
||||
// 1. Опрос сервисов: в FreeBSD/OpenBSD проверяем service / rcctl
|
||||
let mut failed_services = Vec::new();
|
||||
let mut service_logs = String::new();
|
||||
|
||||
// Проверка OpenBSD rcctl ls failed
|
||||
if let Ok(output) = Command::new("rcctl").args(["ls", "failed"]).output() {
|
||||
let str_out = String::from_utf8_lossy(&output.stdout);
|
||||
for line in str_out.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
failed_services.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Чтение логов ядра dmesg
|
||||
let kernel_logs = Command::new("dmesg")
|
||||
.output()
|
||||
.map(|o| {
|
||||
let text = String::from_utf8_lossy(&o.stdout);
|
||||
// Фильтруем последние 40 строк dmesg
|
||||
text.lines()
|
||||
.rev()
|
||||
.take(40)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
// 3. Дисковая подсистема: zpool status (ZFS популярен на FreeBSD) или dmesg
|
||||
let storage_logs = Command::new("zpool")
|
||||
.args(["status", "-x"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_else(|_| {
|
||||
// Ищем ошибки дисков в dmesg
|
||||
kernel_logs
|
||||
.lines()
|
||||
.filter(|l| l.contains("error") || l.contains("CAM") || l.contains("GEOM"))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
});
|
||||
|
||||
// 4. Сеть: ifconfig или /var/log/messages
|
||||
let network_logs = Command::new("ifconfig")
|
||||
.args(["-a"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 5. Звук: cat /dev/sndstat (FreeBSD)
|
||||
let audio_logs = std::fs::read_to_string("/dev/sndstat").unwrap_or_else(|_| {
|
||||
"Audio driver: default BSD sound subsystem".to_string()
|
||||
});
|
||||
|
||||
// Логи из /var/log/messages, если есть
|
||||
if let Ok(messages) = std::fs::read_to_string("/var/log/messages") {
|
||||
let errors = messages
|
||||
.lines()
|
||||
.rev()
|
||||
.filter(|l| l.contains("error") || l.contains("fail") || l.contains("crit"))
|
||||
.take(25)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if !errors.is_empty() {
|
||||
service_logs.push_str(&format!("--- Recent /var/log/messages ---\n{}\n", errors));
|
||||
}
|
||||
}
|
||||
|
||||
Self {
|
||||
failed_services,
|
||||
service_logs,
|
||||
kernel_logs,
|
||||
storage_logs,
|
||||
network_logs,
|
||||
audio_logs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,213 @@
|
||||
use crate::models::CrashReportInfo;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct CrashScanner;
|
||||
|
||||
impl CrashScanner {
|
||||
pub fn scan() -> Vec<CrashReportInfo> {
|
||||
let mut crashes = Vec::new();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
Self::scan_linux_coredumps(&mut crashes);
|
||||
Self::scan_linux_var_crash(&mut crashes);
|
||||
Self::scan_linux_dmesg_segfaults(&mut crashes);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
Self::scan_windows_minidumps(&mut crashes);
|
||||
Self::scan_windows_event_crashes(&mut crashes);
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
Self::scan_macos_diagnostic_reports(&mut crashes);
|
||||
}
|
||||
|
||||
// Generic fallback if empty (e.g. BSD or systems without coredumpctl)
|
||||
if crashes.is_empty() {
|
||||
Self::scan_generic_system_logs(&mut crashes);
|
||||
}
|
||||
|
||||
// Limit to 20 most recent crashes
|
||||
crashes.truncate(20);
|
||||
crashes
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn scan_linux_coredumps(crashes: &mut Vec<CrashReportInfo>) {
|
||||
if let Ok(output) = Command::new("coredumpctl")
|
||||
.args(["list", "--no-legend", "-n", "10"])
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 5 {
|
||||
let time = parts[0..parts.len().saturating_sub(6)].join(" ");
|
||||
let pid = parts.iter().find_map(|p| p.parse::<u32>().ok());
|
||||
let signal = parts.iter().find(|p| p.starts_with("SIG") || **p == "11" || **p == "6").copied().unwrap_or("SIGSEGV");
|
||||
let exe = parts.last().unwrap_or(&"unknown");
|
||||
|
||||
crashes.push(CrashReportInfo {
|
||||
timestamp: if time.is_empty() { "Недавний".to_string() } else { time },
|
||||
process_name: exe.to_string(),
|
||||
pid,
|
||||
signal_or_code: signal.to_string(),
|
||||
fault_module: None,
|
||||
stack_trace_snippet: format!("Core dump сохранен systemd-coredump для исполняемого файла {}", exe),
|
||||
dump_path: Some(format!("coredumpctl info {}", pid.unwrap_or(0))),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn scan_linux_var_crash(crashes: &mut Vec<CrashReportInfo>) {
|
||||
let crash_dir = Path::new("/var/crash");
|
||||
if let Ok(entries) = std::fs::read_dir(crash_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
if filename.ends_with(".crash") {
|
||||
let proc_name = filename.trim_end_matches(".crash").to_string();
|
||||
let metadata = std::fs::metadata(&path).ok();
|
||||
let time_str = metadata
|
||||
.and_then(|m| m.modified().ok())
|
||||
.map(|t| format!("{:?}", t))
|
||||
.unwrap_or_else(|| "Архивный".to_string());
|
||||
|
||||
crashes.push(CrashReportInfo {
|
||||
timestamp: time_str,
|
||||
process_name: proc_name,
|
||||
pid: None,
|
||||
signal_or_code: "Crash Dump".to_string(),
|
||||
fault_module: None,
|
||||
stack_trace_snippet: format!("Дамп аварийного завершения в /var/crash/{}", filename),
|
||||
dump_path: Some(path.to_string_lossy().to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn scan_linux_dmesg_segfaults(crashes: &mut Vec<CrashReportInfo>) {
|
||||
// Поиск segfaults в dmesg через неинтерактивный вызов
|
||||
if let Ok(output) = Command::new("dmesg").args(["-T", "-l", "err,warn"]).output() {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
if line.contains("segfault at") || line.contains("general protection fault") {
|
||||
let mut proc = "kernel".to_string();
|
||||
let mut fault_mod = None;
|
||||
|
||||
if let Some(idx) = line.find("[") {
|
||||
if let Some(_end) = line[idx..].find("]") {
|
||||
let candidate = line[..idx].trim().split_whitespace().last().unwrap_or("");
|
||||
if !candidate.is_empty() {
|
||||
proc = candidate.to_string();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(in_idx) = line.find(" in ") {
|
||||
let after_in = &line[in_idx + 4..];
|
||||
let mod_name = after_in.split_whitespace().next().unwrap_or("");
|
||||
if !mod_name.is_empty() {
|
||||
fault_mod = Some(mod_name.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
crashes.push(CrashReportInfo {
|
||||
timestamp: "Из dmesg".to_string(),
|
||||
process_name: proc,
|
||||
pid: None,
|
||||
signal_or_code: "SIGSEGV (Segmentation Fault)".to_string(),
|
||||
fault_module: fault_mod,
|
||||
stack_trace_snippet: line.trim().to_string(),
|
||||
dump_path: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn scan_windows_minidumps(crashes: &mut Vec<CrashReportInfo>) {
|
||||
let minidump_dir = Path::new(r"C:\Windows\Minidump");
|
||||
if let Ok(entries) = std::fs::read_dir(minidump_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
if name.ends_with(".dmp") {
|
||||
crashes.push(CrashReportInfo {
|
||||
timestamp: "BSOD Crash Dump".to_string(),
|
||||
process_name: "ntoskrnl.exe (Kernel BSOD)".to_string(),
|
||||
pid: Some(4),
|
||||
signal_or_code: "BUGCHECK_DUMP".to_string(),
|
||||
fault_module: Some("Kernel".to_string()),
|
||||
stack_trace_snippet: format!("Дамп аварийного синего экрана смерти: {}", path.display()),
|
||||
dump_path: Some(path.to_string_lossy().to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
fn scan_windows_event_crashes(crashes: &mut Vec<CrashReportInfo>) {
|
||||
let ps_cmd = "Get-WinEvent -FilterHashtable @{LogName='Application'; ProviderName='Application Error'} -MaxEvents 5 -ErrorAction SilentlyContinue | Select-Object TimeCreated, Message | ConvertTo-Json -Compress";
|
||||
if let Ok(output) = Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd]).output() {
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&String::from_utf8_lossy(&output.stdout)) {
|
||||
let items = match val {
|
||||
serde_json::Value::Array(a) => a,
|
||||
serde_json::Value::Object(_) => vec![val],
|
||||
_ => vec![],
|
||||
};
|
||||
for item in items {
|
||||
let msg = item["Message"].as_str().unwrap_or("");
|
||||
let time = item["TimeCreated"].as_str().unwrap_or("Недавно");
|
||||
crashes.push(CrashReportInfo {
|
||||
timestamp: time.to_string(),
|
||||
process_name: "Application Error".to_string(),
|
||||
pid: None,
|
||||
signal_or_code: "0xC0000005 (Access Violation)".to_string(),
|
||||
fault_module: None,
|
||||
stack_trace_snippet: msg.trim().to_string(),
|
||||
dump_path: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn scan_macos_diagnostic_reports(crashes: &mut Vec<CrashReportInfo>) {
|
||||
let reports_dir = Path::new("/Library/Logs/DiagnosticReports");
|
||||
if let Ok(entries) = std::fs::read_dir(reports_dir) {
|
||||
for entry in entries.flatten() {
|
||||
let path = entry.path();
|
||||
let name = path.file_name().unwrap_or_default().to_string_lossy().to_string();
|
||||
if name.ends_with(".ips") || name.ends_with(".crash") {
|
||||
let proc = name.split(['_', '-']).next().unwrap_or(&name).to_string();
|
||||
crashes.push(CrashReportInfo {
|
||||
timestamp: "Diagnostic Report".to_string(),
|
||||
process_name: proc,
|
||||
pid: None,
|
||||
signal_or_code: "EXC_BAD_ACCESS / Crash".to_string(),
|
||||
fault_module: None,
|
||||
stack_trace_snippet: format!("macOS Crash Report: {}", path.display()),
|
||||
dump_path: Some(path.to_string_lossy().to_string()),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_generic_system_logs(_crashes: &mut Vec<CrashReportInfo>) {
|
||||
// Fallback placeholder
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,59 @@
|
||||
use super::provider::{InitSystemProvider, ScanError};
|
||||
use crate::models::FailedService;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct BsdRcProvider;
|
||||
|
||||
impl BsdRcProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for BsdRcProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InitSystemProvider for BsdRcProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"bsd-rc"
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(not(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn get_failed_services(&self) -> Result<Vec<FailedService>, ScanError> {
|
||||
let mut failed = Vec::new();
|
||||
|
||||
// 1. OpenBSD rcctl
|
||||
if let Ok(output) = Command::new("rcctl").args(["ls", "failed"]).output() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
failed.push(FailedService {
|
||||
name: trimmed.to_string(),
|
||||
init_system: "bsd-rc".to_string(),
|
||||
state: "failed".to_string(),
|
||||
description: format!("Daemon {} failed in OpenBSD rcctl", trimmed),
|
||||
logs: "Check /var/log/messages or /var/log/daemon".to_string(),
|
||||
restart_command: format!("sudo rcctl restart {0} && rcctl check {0}", trimmed),
|
||||
status_command: format!("rcctl check {0}", trimmed),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(failed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use super::provider::{InitSystemProvider, ScanError};
|
||||
use crate::models::FailedService;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct LaunchdProvider;
|
||||
|
||||
impl LaunchdProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for LaunchdProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InitSystemProvider for LaunchdProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"launchd"
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn get_failed_services(&self) -> Result<Vec<FailedService>, ScanError> {
|
||||
let mut failed = Vec::new();
|
||||
|
||||
// 1. Опрос списка служб launchctl list
|
||||
if let Ok(output) = Command::new("launchctl").arg("list").output() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines().skip(1) {
|
||||
// Строка имеет вид: PID \t Status \t Label
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
let pid_str = parts[0];
|
||||
let status_str = parts[1];
|
||||
let label = parts[2];
|
||||
|
||||
// Игнорируем стандартные успешные статусы (0)
|
||||
if let Ok(exit_code) = status_str.parse::<i32>() {
|
||||
if exit_code != 0 && exit_code != -9 && exit_code != -15 {
|
||||
// Получаем короткий журнал сбоя через `log show`
|
||||
let logs = Self::collect_logs(label);
|
||||
|
||||
failed.push(FailedService {
|
||||
name: label.to_string(),
|
||||
init_system: "launchd".to_string(),
|
||||
state: format!("exit({}) PID: {}", exit_code, pid_str),
|
||||
description: format!("Служба launchd завершилась с кодом ошибки {}", exit_code),
|
||||
logs,
|
||||
restart_command: format!("launchctl kickstart -k gui/$(id -u)/{0} || sudo launchctl kickstart -k system/{0}", label),
|
||||
status_command: format!("launchctl print gui/$(id -u)/{0} || launchctl print system/{0}", label),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(failed)
|
||||
}
|
||||
}
|
||||
|
||||
impl LaunchdProvider {
|
||||
fn collect_logs(label: &str) -> String {
|
||||
let predicate = format!("subsystem contains \"{}\" OR process contains \"{}\"", label, label);
|
||||
if let Ok(output) = Command::new("log")
|
||||
.args(["show", "--predicate", &predicate, "--last", "15m", "--style", "compact"])
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
if !text.trim().is_empty() {
|
||||
let lines: Vec<&str> = text.lines().rev().take(30).collect();
|
||||
let mut rev_lines = lines;
|
||||
rev_lines.reverse();
|
||||
return rev_lines.join("\n");
|
||||
}
|
||||
}
|
||||
format!("Записи о падении {} в unified log отсутствуют.", label)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
pub mod provider;
|
||||
pub mod systemd;
|
||||
pub mod openrc;
|
||||
pub mod runit;
|
||||
pub mod bsd_rc;
|
||||
pub mod launchd;
|
||||
pub mod windows_scm;
|
||||
|
||||
pub use provider::{InitSystemProvider, ScanError};
|
||||
pub use systemd::SystemdProvider;
|
||||
pub use openrc::OpenRcProvider;
|
||||
pub use runit::RunitProvider;
|
||||
pub use bsd_rc::BsdRcProvider;
|
||||
pub use launchd::LaunchdProvider;
|
||||
pub use windows_scm::WindowsScmProvider;
|
||||
|
||||
/// Автоматически определяет активную систему инициализации в текущем окружении
|
||||
pub fn detect_init_system() -> Box<dyn InitSystemProvider> {
|
||||
// 1. macOS launchd
|
||||
let launchd = LaunchdProvider::new();
|
||||
if launchd.is_active() {
|
||||
return Box::new(launchd);
|
||||
}
|
||||
|
||||
// 2. Windows SCM
|
||||
let win_scm = WindowsScmProvider::new();
|
||||
if win_scm.is_active() {
|
||||
return Box::new(win_scm);
|
||||
}
|
||||
|
||||
// 3. systemd
|
||||
let systemd = SystemdProvider::new();
|
||||
if systemd.is_active() {
|
||||
return Box::new(systemd);
|
||||
}
|
||||
|
||||
// 4. OpenRC
|
||||
let openrc = OpenRcProvider::new();
|
||||
if openrc.is_active() {
|
||||
return Box::new(openrc);
|
||||
}
|
||||
|
||||
// 5. runit
|
||||
let runit = RunitProvider::new();
|
||||
if runit.is_active() {
|
||||
return Box::new(runit);
|
||||
}
|
||||
|
||||
// 6. BSD rc
|
||||
let bsd = BsdRcProvider::new();
|
||||
if bsd.is_active() {
|
||||
return Box::new(bsd);
|
||||
}
|
||||
|
||||
// Fallback: если ничего явно не определилось
|
||||
#[cfg(target_os = "windows")]
|
||||
return Box::new(WindowsScmProvider::new());
|
||||
#[cfg(target_os = "macos")]
|
||||
return Box::new(LaunchdProvider::new());
|
||||
#[cfg(not(any(target_os = "windows", target_os = "macos")))]
|
||||
Box::new(SystemdProvider::new())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_detect_init_system() {
|
||||
let init = detect_init_system();
|
||||
assert!(!init.name().is_empty());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
use super::provider::{InitSystemProvider, ScanError};
|
||||
use crate::models::FailedService;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct OpenRcProvider;
|
||||
|
||||
impl OpenRcProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for OpenRcProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InitSystemProvider for OpenRcProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"openrc"
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
if Path::new("/run/openrc").exists() || Path::new("/etc/init.d").exists() && Path::new("/sbin/rc-status").exists() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(comm) = std::fs::read_to_string("/proc/1/comm") {
|
||||
if comm.trim() == "openrc-init" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn get_failed_services(&self) -> Result<Vec<FailedService>, ScanError> {
|
||||
// Опрос crashed сервисов через rc-status -c
|
||||
let output = Command::new("rc-status")
|
||||
.args(["-c"])
|
||||
.output()
|
||||
.or_else(|_| Command::new("rc-status").args(["-s"]).output())
|
||||
.map_err(|e| ScanError::CommandFailed(e.to_string()))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
let line_trimmed = line.trim();
|
||||
if line_trimmed.contains("crashed") || line_trimmed.contains("failed") || line_trimmed.contains("broken") {
|
||||
let parts: Vec<&str> = line_trimmed.split_whitespace().collect();
|
||||
if let Some(service) = parts.first() {
|
||||
let svc_name = service.to_string();
|
||||
|
||||
// Читаем логи из /var/log/rc.log или syslog
|
||||
let logs = std::fs::read_to_string("/var/log/rc.log")
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.rev()
|
||||
.filter(|l| l.contains(&svc_name))
|
||||
.take(30)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
failed.push(FailedService {
|
||||
name: svc_name.clone(),
|
||||
init_system: "openrc".to_string(),
|
||||
state: "crashed".to_string(),
|
||||
description: format!("Service {} crashed under OpenRC supervision", svc_name),
|
||||
logs: if logs.is_empty() { "No entries in /var/log/rc.log".to_string() } else { logs },
|
||||
restart_command: format!("sudo rc-service {0} restart && sudo rc-service {0} status", svc_name),
|
||||
status_command: format!("rc-service {0} status", svc_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(failed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
use crate::models::FailedService;
|
||||
use std::fmt;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ScanError {
|
||||
CommandFailed(String),
|
||||
PermissionDenied(String),
|
||||
Io(String),
|
||||
NotSupported(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for ScanError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
ScanError::CommandFailed(e) => write!(f, "Init command error: {}", e),
|
||||
ScanError::PermissionDenied(e) => write!(f, "Permission denied: {}", e),
|
||||
ScanError::Io(e) => write!(f, "I/O error: {}", e),
|
||||
ScanError::NotSupported(e) => write!(f, "Init system not supported: {}", e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ScanError {}
|
||||
|
||||
/// Единый трейт для взаимодействия с системами инициализации ОС
|
||||
pub trait InitSystemProvider: Send + Sync {
|
||||
/// Имя системы инициализации (systemd, openrc, runit, s6, dinit, bsd-rc)
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Проверка, активна ли данная система инициализации в текущем окружении
|
||||
fn is_active(&self) -> bool;
|
||||
|
||||
/// Получение списка упавших или аварийно завершенных служб
|
||||
fn get_failed_services(&self) -> Result<Vec<FailedService>, ScanError>;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
use super::provider::{InitSystemProvider, ScanError};
|
||||
use crate::models::FailedService;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct RunitProvider;
|
||||
|
||||
impl RunitProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for RunitProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InitSystemProvider for RunitProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"runit"
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
if Path::new("/run/runit").exists() || Path::new("/var/service").exists() && Path::new("/usr/bin/sv").exists() {
|
||||
return true;
|
||||
}
|
||||
if let Ok(comm) = std::fs::read_to_string("/proc/1/comm") {
|
||||
if comm.trim() == "runit" || comm.trim() == "runit-init" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn get_failed_services(&self) -> Result<Vec<FailedService>, ScanError> {
|
||||
let output = Command::new("sh")
|
||||
.args(["-c", "sv status /var/service/* 2>&1"])
|
||||
.output()
|
||||
.map_err(|e| ScanError::CommandFailed(e.to_string()))?;
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
let line_trimmed = line.trim();
|
||||
// runit выводит: "down: /var/service/sshd: 0s, normally up, want up" или "fail: ..."
|
||||
if line_trimmed.starts_with("down:") || line_trimmed.starts_with("fail:") {
|
||||
let parts: Vec<&str> = line_trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
let full_path = parts[1].trim_end_matches(':');
|
||||
let svc_name = Path::new(full_path)
|
||||
.file_name()
|
||||
.and_then(|n| n.to_str())
|
||||
.unwrap_or(full_path)
|
||||
.to_string();
|
||||
|
||||
// Читаем логи svlogd: /var/log/<service>/current
|
||||
let log_path = format!("/var/log/{}/current", svc_name);
|
||||
let logs = std::fs::read_to_string(&log_path)
|
||||
.unwrap_or_default()
|
||||
.lines()
|
||||
.rev()
|
||||
.take(30)
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
failed.push(FailedService {
|
||||
name: svc_name.clone(),
|
||||
init_system: "runit".to_string(),
|
||||
state: "down/failed".to_string(),
|
||||
description: format!("Service {} is down or failed in runit", svc_name),
|
||||
logs: if logs.is_empty() { format!("No svlogd logs at {}", log_path) } else { logs },
|
||||
restart_command: format!("sudo sv restart {0} && sv status {0}", svc_name),
|
||||
status_command: format!("sv status {0}", svc_name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(failed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
use super::provider::{InitSystemProvider, ScanError};
|
||||
use crate::models::FailedService;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct SystemdProvider;
|
||||
|
||||
impl SystemdProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for SystemdProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InitSystemProvider for SystemdProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"systemd"
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
// 1. Проверка /run/systemd/system
|
||||
if Path::new("/run/systemd/system").exists() {
|
||||
return true;
|
||||
}
|
||||
// 2. Проверка /proc/1/comm == "systemd"
|
||||
if let Ok(comm) = std::fs::read_to_string("/proc/1/comm") {
|
||||
if comm.trim() == "systemd" {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn get_failed_services(&self) -> Result<Vec<FailedService>, ScanError> {
|
||||
let output = Command::new("systemctl")
|
||||
.args(["--failed", "--all", "--no-legend", "--plain"])
|
||||
.output()
|
||||
.map_err(|e| ScanError::CommandFailed(e.to_string()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
if stderr.contains("Access denied") || stderr.contains("Permission denied") {
|
||||
return Err(ScanError::PermissionDenied(stderr.to_string()));
|
||||
}
|
||||
return Err(ScanError::CommandFailed(stderr.to_string()));
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
let mut failed = Vec::new();
|
||||
|
||||
for line in stdout.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let unit = parts[0].to_string();
|
||||
|
||||
// Читаем журнал юнита: journalctl -u <unit> -b -n 40 --no-pager
|
||||
let logs = crate::auth::PrivilegeManager::exec_privileged(
|
||||
"journalctl",
|
||||
&["-u", &unit, "-b", "-n", "40", "--no-pager"],
|
||||
)
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
failed.push(FailedService {
|
||||
name: unit.clone(),
|
||||
init_system: "systemd".to_string(),
|
||||
state: "failed".to_string(),
|
||||
description: format!("Unit {} is in failed state in systemd", unit),
|
||||
logs,
|
||||
restart_command: format!("sudo systemctl restart {0} && systemctl status {0}", unit),
|
||||
status_command: format!("systemctl status {0} -l", unit),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(failed)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
use super::provider::{InitSystemProvider, ScanError};
|
||||
use crate::models::FailedService;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct WindowsScmProvider;
|
||||
|
||||
impl WindowsScmProvider {
|
||||
pub fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for WindowsScmProvider {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl InitSystemProvider for WindowsScmProvider {
|
||||
fn name(&self) -> &'static str {
|
||||
"windows-scm"
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
true
|
||||
}
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
{
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
fn get_failed_services(&self) -> Result<Vec<FailedService>, ScanError> {
|
||||
let mut failed = Vec::new();
|
||||
|
||||
// 1. Поиск служб Windows с автоматическим запуском, находящихся в остановленном состоянии
|
||||
let ps_cmd = "Get-CimInstance Win32_Service | Where-Object { $_.StartMode -eq 'Auto' -and $_.State -ne 'Running' } | Select-Object Name, DisplayName, State, ExitCode | ConvertTo-Json -Compress";
|
||||
|
||||
if let Ok(output) = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd])
|
||||
.output()
|
||||
{
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
if let Ok(val) = serde_json::from_str::<serde_json::Value>(&stdout) {
|
||||
let items = match val {
|
||||
serde_json::Value::Array(arr) => arr,
|
||||
serde_json::Value::Object(_) => vec![val],
|
||||
_ => vec![],
|
||||
};
|
||||
|
||||
for item in items {
|
||||
let name = item["Name"].as_str().unwrap_or("Unknown").to_string();
|
||||
let display_name = item["DisplayName"].as_str().unwrap_or(&name).to_string();
|
||||
let state = item["State"].as_str().unwrap_or("Stopped").to_string();
|
||||
let exit_code = item["ExitCode"].as_i64().unwrap_or(0);
|
||||
|
||||
let logs = Self::collect_event_logs(&name);
|
||||
|
||||
failed.push(FailedService {
|
||||
name: name.clone(),
|
||||
init_system: "windows-scm".to_string(),
|
||||
state: format!("{} (ExitCode: {})", state, exit_code),
|
||||
description: format!("Служба Windows '{}' ({}) не запущена", display_name, name),
|
||||
logs,
|
||||
restart_command: format!("Start-Service -Name '{}'", name),
|
||||
status_command: format!("Get-Service -Name '{}'", name),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(failed)
|
||||
}
|
||||
}
|
||||
|
||||
impl WindowsScmProvider {
|
||||
fn collect_event_logs(service_name: &str) -> String {
|
||||
let ps_cmd = format!(
|
||||
"Get-WinEvent -FilterHashtable @{{LogName='System'; ProviderName='Service Control Manager'}} -MaxEvents 5 -ErrorAction SilentlyContinue | Where-Object {{ $_.Message -like '*{}*' }} | ForEach-Object {{ $_.TimeCreated.ToString('s') + ' ' + $_.Message }}",
|
||||
service_name
|
||||
);
|
||||
if let Ok(output) = Command::new("powershell")
|
||||
.args(["-NoProfile", "-NonInteractive", "-Command", &ps_cmd])
|
||||
.output()
|
||||
{
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
if !text.trim().is_empty() {
|
||||
return text.trim().to_string();
|
||||
}
|
||||
}
|
||||
format!("События сбоя службы {} в журнале System Event Log не найдены.", service_name)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct KernelScanResult {
|
||||
pub raw_logs: String,
|
||||
pub oom_events: Vec<String>,
|
||||
pub gpu_hangs: Vec<String>,
|
||||
pub io_errors: Vec<String>,
|
||||
pub csum_errors: Vec<String>,
|
||||
pub tainted_value: u64,
|
||||
pub tainted_flags: Vec<String>,
|
||||
pub permission_denied: bool,
|
||||
}
|
||||
|
||||
pub struct KernelScanner;
|
||||
|
||||
impl KernelScanner {
|
||||
pub fn scan() -> KernelScanResult {
|
||||
let mut result = KernelScanResult::default();
|
||||
|
||||
// 1. Читаем /proc/sys/kernel/tainted
|
||||
if let Ok(tainted_str) = std::fs::read_to_string("/proc/sys/kernel/tainted") {
|
||||
if let Ok(val) = tainted_str.trim().parse::<u64>() {
|
||||
result.tainted_value = val;
|
||||
result.tainted_flags = Self::decode_tainted(val);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Читаем dmesg / /dev/kmsg
|
||||
let dmesg_out = crate::auth::PrivilegeManager::exec_privileged("dmesg", &["--level=err,crit,alert,emerg"])
|
||||
.or_else(|_| crate::auth::PrivilegeManager::exec_privileged("dmesg", &[]));
|
||||
|
||||
let logs = match dmesg_out {
|
||||
Ok(output) => {
|
||||
if !output.status.success() {
|
||||
let err = String::from_utf8_lossy(&output.stderr);
|
||||
if err.contains("Operation not permitted") || err.contains("Permission denied") {
|
||||
result.permission_denied = true;
|
||||
}
|
||||
String::new()
|
||||
} else {
|
||||
String::from_utf8_lossy(&output.stdout).to_string()
|
||||
}
|
||||
}
|
||||
Err(_) => {
|
||||
// Попытка прямого чтения /dev/kmsg
|
||||
match std::fs::read_to_string("/dev/kmsg") {
|
||||
Ok(kmsg) => kmsg,
|
||||
Err(e) => {
|
||||
if e.kind() == std::io::ErrorKind::PermissionDenied {
|
||||
result.permission_denied = true;
|
||||
}
|
||||
String::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
result.raw_logs = logs.clone();
|
||||
|
||||
// 3. Парсинг критических паттернов
|
||||
for line in logs.lines() {
|
||||
let lower = line.to_lowercase();
|
||||
|
||||
// OOM-Killer
|
||||
if lower.contains("out of memory") || lower.contains("oom-killer") || lower.contains("invoked oom-killer") {
|
||||
result.oom_events.push(line.to_string());
|
||||
}
|
||||
|
||||
// GPU Hangs (NVIDIA Xid, AMDGPU reset, Intel i915 hang)
|
||||
if line.contains("NVRM: Xid") || lower.contains("amdgpu reset") || lower.contains("gpu hang") || lower.contains("drm/i915: reset") {
|
||||
result.gpu_hangs.push(line.to_string());
|
||||
}
|
||||
|
||||
// I/O Errors
|
||||
if lower.contains("i/o error") || lower.contains("blk_update_request") || lower.contains("ext4-fs error") || lower.contains("btrfs: error") {
|
||||
result.io_errors.push(line.to_string());
|
||||
}
|
||||
|
||||
// Checksum errors (Btrfs / ZFS csum)
|
||||
if lower.contains("checksum error") || lower.contains("csum failed") || lower.contains("checksum mismatch") {
|
||||
result.csum_errors.push(line.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
|
||||
/// Декодирует флаги /proc/sys/kernel/tainted
|
||||
pub fn decode_tainted(val: u64) -> Vec<String> {
|
||||
let mut flags = Vec::new();
|
||||
if val & (1 << 0) != 0 { flags.push("P: Proprietary module loaded".to_string()); }
|
||||
if val & (1 << 1) != 0 { flags.push("F: Module force loaded".to_string()); }
|
||||
if val & (1 << 2) != 0 { flags.push("S: SMP with CPUs not designed for SMP".to_string()); }
|
||||
if val & (1 << 3) != 0 { flags.push("R: Module force unloaded".to_string()); }
|
||||
if val & (1 << 4) != 0 { flags.push("M: Machine Check Exception occurred".to_string()); }
|
||||
if val & (1 << 5) != 0 { flags.push("B: Bad page referenced".to_string()); }
|
||||
if val & (1 << 6) != 0 { flags.push("U: User requested taint".to_string()); }
|
||||
if val & (1 << 7) != 0 { flags.push("D: Kernel died / oops".to_string()); }
|
||||
if val & (1 << 8) != 0 { flags.push("A: ACPI table overridden".to_string()); }
|
||||
if val & (1 << 9) != 0 { flags.push("W: Kernel warning issued".to_string()); }
|
||||
if val & (1 << 10) != 0 { flags.push("C: Staging driver loaded".to_string()); }
|
||||
if val & (1 << 11) != 0 { flags.push("I: Firmware workaround active".to_string()); }
|
||||
if val & (1 << 12) != 0 { flags.push("O: External out-of-tree module loaded".to_string()); }
|
||||
if val & (1 << 13) != 0 { flags.push("E: Unsigned module loaded".to_string()); }
|
||||
if val & (1 << 14) != 0 { flags.push("L: Soft lockup occurred".to_string()); }
|
||||
if val & (1 << 15) != 0 { flags.push("K: Kernel livepatched".to_string()); }
|
||||
if val & (1 << 16) != 0 { flags.push("X: Auxiliary taint".to_string()); }
|
||||
if val & (1 << 17) != 0 { flags.push("T: Built with struct layout randomization".to_string()); }
|
||||
flags
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
use std::process::Command;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct SystemLimitsResult {
|
||||
pub max_user_watches: u64,
|
||||
pub file_nr_allocated: u64,
|
||||
pub file_nr_max: u64,
|
||||
pub pid_max: u64,
|
||||
pub file_descriptors_exhausted: bool,
|
||||
pub recent_coredumps: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct LimitsScanner;
|
||||
|
||||
impl LimitsScanner {
|
||||
pub fn scan() -> SystemLimitsResult {
|
||||
let mut result = SystemLimitsResult::default();
|
||||
|
||||
// 1. inotify max_user_watches
|
||||
if let Ok(val) = std::fs::read_to_string("/proc/sys/fs/inotify/max_user_watches") {
|
||||
result.max_user_watches = val.trim().parse().unwrap_or(0);
|
||||
}
|
||||
|
||||
// 2. file-nr: allocated, unused, max
|
||||
if let Ok(val) = std::fs::read_to_string("/proc/sys/fs/file-nr") {
|
||||
let parts: Vec<&str> = val.split_whitespace().collect();
|
||||
if parts.len() >= 3 {
|
||||
result.file_nr_allocated = parts[0].parse().unwrap_or(0);
|
||||
result.file_nr_max = parts[2].parse().unwrap_or(0);
|
||||
if result.file_nr_max > 0 {
|
||||
let ratio = result.file_nr_allocated as f64 / result.file_nr_max as f64;
|
||||
if ratio >= 0.95 {
|
||||
result.file_descriptors_exhausted = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. pid_max
|
||||
if let Ok(val) = std::fs::read_to_string("/proc/sys/kernel/pid_max") {
|
||||
result.pid_max = val.trim().parse().unwrap_or(0);
|
||||
}
|
||||
|
||||
// 4. coredumpctl
|
||||
if let Ok(output) = Command::new("coredumpctl").args(["list", "--no-legend", "-n", "5"]).output() {
|
||||
if output.status.success() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.is_empty() {
|
||||
result.recent_coredumps.push(trimmed.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::process::Command;
|
||||
|
||||
pub struct LinuxRawData {
|
||||
pub failed_units: Vec<String>,
|
||||
pub systemd_logs: String,
|
||||
pub kernel_logs: String,
|
||||
pub storage_logs: String,
|
||||
pub network_logs: String,
|
||||
pub audio_logs: String,
|
||||
}
|
||||
|
||||
impl LinuxRawData {
|
||||
pub fn collect() -> Self {
|
||||
// 1. Опрос systemd на упавшие юниты
|
||||
let failed_output = Command::new("systemctl")
|
||||
.args(["--failed", "--no-legend", "--plain"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
let mut failed_units = Vec::new();
|
||||
for line in failed_output.lines() {
|
||||
if let Some(unit) = line.split_whitespace().next() {
|
||||
if !unit.is_empty() {
|
||||
failed_units.push(unit.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Логи упавших сервисов
|
||||
let mut systemd_logs = String::new();
|
||||
if !failed_units.is_empty() {
|
||||
for unit in failed_units.iter().take(3) {
|
||||
let status = Command::new("journalctl")
|
||||
.args(["-u", unit, "-n", "20", "--no-pager"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
systemd_logs.push_str(&format!("--- Logs for {} ---\n{}\n", unit, status));
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Критические логи ядра
|
||||
let kernel_logs = Command::new("journalctl")
|
||||
.args(["-k", "-p", "3", "-n", "30", "--no-pager"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_else(|_| {
|
||||
Command::new("dmesg")
|
||||
.args(["--level=err,crit,alert,emerg"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default()
|
||||
});
|
||||
|
||||
// 3. Дисковые ошибки
|
||||
let storage_logs = Command::new("journalctl")
|
||||
.args(["-g", "(I/O error|EXT4-fs error|BTRFS error|blk_update_request)", "-n", "20", "--no-pager"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 4. Сеть
|
||||
let network_logs = Command::new("journalctl")
|
||||
.args(["-u", "NetworkManager", "-p", "4", "-n", "20", "--no-pager"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 5. Звук
|
||||
let audio_logs = Command::new("journalctl")
|
||||
.args(["--user-unit", "pipewire", "-p", "4", "-n", "20", "--no-pager"])
|
||||
.output()
|
||||
.map(|o| String::from_utf8_lossy(&o.stdout).to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
Self {
|
||||
failed_units,
|
||||
systemd_logs,
|
||||
kernel_logs,
|
||||
storage_logs,
|
||||
network_logs,
|
||||
audio_logs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
use crate::models::LiveMetricsSnapshot;
|
||||
use std::fs;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
static PREV_CPU_SAMPLE: Mutex<Option<(u64, u64, Instant)>> = Mutex::new(None);
|
||||
static PREV_NET_SAMPLE: Mutex<Option<(u64, u64, Instant)>> = Mutex::new(None);
|
||||
static PREV_DISK_SAMPLE: Mutex<Option<(u64, u64, Instant)>> = Mutex::new(None);
|
||||
|
||||
pub struct LiveMetricsScanner;
|
||||
|
||||
impl LiveMetricsScanner {
|
||||
pub fn sample() -> LiveMetricsSnapshot {
|
||||
let mut snap = LiveMetricsSnapshot::default();
|
||||
|
||||
snap.cpu_usage_percent = Self::sample_cpu_usage();
|
||||
Self::sample_memory(&mut snap);
|
||||
Self::sample_thermals(&mut snap);
|
||||
Self::sample_network_io(&mut snap);
|
||||
Self::sample_disk_io(&mut snap);
|
||||
|
||||
snap
|
||||
}
|
||||
|
||||
fn sample_cpu_usage() -> f32 {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(content) = fs::read_to_string("/proc/stat") {
|
||||
if let Some(first_line) = content.lines().next() {
|
||||
let parts: Vec<&str> = first_line.split_whitespace().skip(1).collect();
|
||||
if parts.len() >= 4 {
|
||||
let user: u64 = parts[0].parse().unwrap_or(0);
|
||||
let nice: u64 = parts[1].parse().unwrap_or(0);
|
||||
let system: u64 = parts[2].parse().unwrap_or(0);
|
||||
let idle: u64 = parts[3].parse().unwrap_or(0);
|
||||
let iowait: u64 = parts.get(4).and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let irq: u64 = parts.get(5).and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
let softirq: u64 = parts.get(6).and_then(|v| v.parse().ok()).unwrap_or(0);
|
||||
|
||||
let total_idle = idle + iowait;
|
||||
let total_active = user + nice + system + irq + softirq;
|
||||
let total = total_idle + total_active;
|
||||
let now = Instant::now();
|
||||
|
||||
if let Ok(mut lock) = PREV_CPU_SAMPLE.lock() {
|
||||
if let Some((prev_total, prev_idle, _)) = *lock {
|
||||
let diff_total = total.saturating_sub(prev_total);
|
||||
let diff_idle = total_idle.saturating_sub(prev_idle);
|
||||
*lock = Some((total, total_idle, now));
|
||||
|
||||
if diff_total > 0 {
|
||||
let usage = ((diff_total.saturating_sub(diff_idle)) as f32 / diff_total as f32) * 100.0;
|
||||
return usage.clamp(0.0, 100.0);
|
||||
}
|
||||
} else {
|
||||
*lock = Some((total, total_idle, now));
|
||||
return 15.0; // Значение по умолчанию на 1-м тике
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
20.0
|
||||
}
|
||||
|
||||
fn sample_memory(snap: &mut LiveMetricsSnapshot) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(meminfo) = fs::read_to_string("/proc/meminfo") {
|
||||
for line in meminfo.lines() {
|
||||
let mut parts = line.split(':');
|
||||
let key = parts.next().unwrap_or("").trim();
|
||||
let val_str = parts.next().unwrap_or("").trim();
|
||||
let kb = val_str.split_whitespace().next().and_then(|v| v.parse::<u64>().ok()).unwrap_or(0);
|
||||
let bytes = kb * 1024;
|
||||
|
||||
match key {
|
||||
"MemTotal" => snap.ram_total_bytes = bytes,
|
||||
"MemAvailable" => {
|
||||
let avail = bytes;
|
||||
if snap.ram_total_bytes >= avail {
|
||||
snap.ram_used_bytes = snap.ram_total_bytes - avail;
|
||||
}
|
||||
}
|
||||
"SwapTotal" => snap.swap_total_bytes = bytes,
|
||||
"SwapFree" => {
|
||||
let free = bytes;
|
||||
if snap.swap_total_bytes >= free {
|
||||
snap.swap_used_bytes = snap.swap_total_bytes - free;
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_thermals(snap: &mut LiveMetricsSnapshot) {
|
||||
let mut max_temp = 0.0f32;
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(entries) = fs::read_dir("/sys/class/thermal") {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if p.file_name().map(|n| n.to_string_lossy().starts_with("thermal_zone")).unwrap_or(false) {
|
||||
if let Ok(content) = fs::read_to_string(p.join("temp")) {
|
||||
if let Ok(milli) = content.trim().parse::<i64>() {
|
||||
let c = milli as f32 / 1000.0;
|
||||
if c > max_temp && c < 150.0 {
|
||||
max_temp = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Детекция активного троттлинга
|
||||
let mut throttle = max_temp >= 90.0;
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(entries) = fs::read_dir("/sys/devices/system/cpu") {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path().join("thermal_throttle/core_throttle_count");
|
||||
if let Ok(cnt) = fs::read_to_string(p) {
|
||||
if let Ok(num) = cnt.trim().parse::<u64>() {
|
||||
if num > 0 {
|
||||
throttle = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
snap.max_cpu_temp_c = if max_temp > 0.0 { max_temp } else { 38.0 };
|
||||
snap.is_throttling = throttle;
|
||||
}
|
||||
|
||||
fn sample_network_io(snap: &mut LiveMetricsSnapshot) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(content) = fs::read_to_string("/proc/net/dev") {
|
||||
let mut total_rx = 0u64;
|
||||
let mut total_tx = 0u64;
|
||||
for line in content.lines().skip(2) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 10 {
|
||||
let iface = parts[0].trim_end_matches(':');
|
||||
if iface != "lo" {
|
||||
let rx: u64 = parts[1].parse().unwrap_or(0);
|
||||
let tx: u64 = parts[9].parse().unwrap_or(0);
|
||||
total_rx += rx;
|
||||
total_tx += tx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if let Ok(mut lock) = PREV_NET_SAMPLE.lock() {
|
||||
if let Some((prev_rx, prev_tx, prev_t)) = *lock {
|
||||
let dt = now.duration_since(prev_t).as_secs_f32().max(0.1);
|
||||
snap.net_rx_kbps = ((total_rx.saturating_sub(prev_rx)) as f32 / (dt * 1024.0)) as u64;
|
||||
snap.net_tx_kbps = ((total_tx.saturating_sub(prev_tx)) as f32 / (dt * 1024.0)) as u64;
|
||||
*lock = Some((total_rx, total_tx, now));
|
||||
} else {
|
||||
*lock = Some((total_rx, total_tx, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sample_disk_io(snap: &mut LiveMetricsSnapshot) {
|
||||
#[cfg(target_os = "linux")]
|
||||
if let Ok(content) = fs::read_to_string("/proc/diskstats") {
|
||||
let mut read_sectors = 0u64;
|
||||
let mut write_sectors = 0u64;
|
||||
for line in content.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 14 {
|
||||
let dev_name = parts[2];
|
||||
if dev_name.starts_with("sd") || dev_name.starts_with("nvme") {
|
||||
if !dev_name.chars().last().unwrap_or(' ').is_ascii_digit() || dev_name.contains('n') {
|
||||
let rs: u64 = parts[5].parse().unwrap_or(0);
|
||||
let ws: u64 = parts[9].parse().unwrap_or(0);
|
||||
read_sectors += rs;
|
||||
write_sectors += ws;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let now = Instant::now();
|
||||
if let Ok(mut lock) = PREV_DISK_SAMPLE.lock() {
|
||||
if let Some((prev_r, prev_w, prev_t)) = *lock {
|
||||
let dt = now.duration_since(prev_t).as_secs_f32().max(0.1);
|
||||
// 1 sector = 512 bytes = 0.5 KB
|
||||
snap.disk_read_kbps = ((read_sectors.saturating_sub(prev_r) * 512) as f32 / (dt * 1024.0)) as u64;
|
||||
snap.disk_write_kbps = ((write_sectors.saturating_sub(prev_w) * 512) as f32 / (dt * 1024.0)) as u64;
|
||||
*lock = Some((read_sectors, write_sectors, now));
|
||||
} else {
|
||||
*lock = Some((read_sectors, write_sectors, now));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
pub mod init;
|
||||
pub mod kernel;
|
||||
pub mod limits;
|
||||
pub mod pressure;
|
||||
pub mod storage;
|
||||
pub mod hardware;
|
||||
pub mod network_deep;
|
||||
pub mod crashes;
|
||||
pub mod security;
|
||||
pub mod live;
|
||||
pub mod linux;
|
||||
pub mod bsd;
|
||||
|
||||
use crate::auth::PrivilegeManager;
|
||||
use crate::models::{CrashReportInfo, FailedService, SecurityAuditReport};
|
||||
pub use init::{detect_init_system, InitSystemProvider};
|
||||
pub use kernel::{KernelScanner, KernelScanResult};
|
||||
pub use limits::{LimitsScanner, SystemLimitsResult};
|
||||
pub use pressure::{PressureScanner, PsiMetrics};
|
||||
pub use storage::{StorageScanner, MountHealth};
|
||||
pub use hardware::{HardwareScanner, HardwareAuditResult};
|
||||
pub use network_deep::{NetworkDeepScanner, NetworkDeepScanResult};
|
||||
pub use crashes::CrashScanner;
|
||||
pub use security::SecurityScanner;
|
||||
pub use live::LiveMetricsScanner;
|
||||
|
||||
pub struct UnifiedRawData {
|
||||
pub os_name: String,
|
||||
pub init_name: String,
|
||||
pub is_root: bool,
|
||||
pub failed_services: Vec<FailedService>,
|
||||
pub kernel_scan: KernelScanResult,
|
||||
pub limits_scan: SystemLimitsResult,
|
||||
pub pressure_scan: PsiMetrics,
|
||||
pub storage_mounts: Vec<MountHealth>,
|
||||
pub hardware_scan: HardwareAuditResult,
|
||||
pub hardware_info: crate::models::HardwareInfo,
|
||||
pub network_deep_scan: NetworkDeepScanResult,
|
||||
pub crashes: Vec<CrashReportInfo>,
|
||||
pub security: SecurityAuditReport,
|
||||
pub service_logs: String,
|
||||
pub network_logs: String,
|
||||
pub audio_logs: String,
|
||||
}
|
||||
|
||||
impl UnifiedRawData {
|
||||
pub fn collect(mode: Option<crate::auth::AuthMode>) -> Self {
|
||||
if let Some(m) = mode {
|
||||
let _ = PrivilegeManager::ensure_privileges(m);
|
||||
}
|
||||
|
||||
let is_root = PrivilegeManager::is_current_user_root()
|
||||
|| matches!(
|
||||
PrivilegeManager::ensure_privileges(mode.unwrap_or(crate::auth::AuthMode::Tui)),
|
||||
crate::auth::PrivilegeStatus::Elevated
|
||||
);
|
||||
let init_provider = detect_init_system();
|
||||
let init_name = init_provider.name().to_string();
|
||||
let failed_services = init_provider.get_failed_services().unwrap_or_default();
|
||||
|
||||
let kernel_scan = KernelScanner::scan();
|
||||
let limits_scan = LimitsScanner::scan();
|
||||
let pressure_scan = PressureScanner::scan();
|
||||
let storage_mounts = StorageScanner::scan();
|
||||
let (hardware_scan, hardware_info) = HardwareScanner::scan();
|
||||
let network_deep_scan = NetworkDeepScanner::scan();
|
||||
let crashes = CrashScanner::scan();
|
||||
let security = SecurityScanner::scan();
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
let (service_logs, network_logs, audio_logs) = {
|
||||
let data = linux::LinuxRawData::collect();
|
||||
(data.systemd_logs, data.network_logs, data.audio_logs)
|
||||
};
|
||||
|
||||
#[cfg(any(target_os = "freebsd", target_os = "openbsd", target_os = "netbsd"))]
|
||||
let (service_logs, network_logs, audio_logs) = {
|
||||
let data = bsd::BsdRawData::collect();
|
||||
(data.service_logs, data.network_logs, data.audio_logs)
|
||||
};
|
||||
|
||||
#[cfg(not(any(target_os = "linux", target_os = "freebsd", target_os = "openbsd", target_os = "netbsd")))]
|
||||
let (service_logs, network_logs, audio_logs) = (String::new(), String::new(), String::new());
|
||||
|
||||
let os_name = if cfg!(target_os = "linux") {
|
||||
"Linux".to_string()
|
||||
} else if cfg!(target_os = "freebsd") {
|
||||
"FreeBSD".to_string()
|
||||
} else if cfg!(target_os = "openbsd") {
|
||||
"OpenBSD".to_string()
|
||||
} else {
|
||||
std::env::consts::OS.to_string()
|
||||
};
|
||||
|
||||
UnifiedRawData {
|
||||
os_name,
|
||||
init_name,
|
||||
is_root,
|
||||
failed_services,
|
||||
kernel_scan,
|
||||
limits_scan,
|
||||
pressure_scan,
|
||||
storage_mounts,
|
||||
hardware_scan,
|
||||
hardware_info,
|
||||
network_deep_scan,
|
||||
crashes,
|
||||
security,
|
||||
service_logs,
|
||||
network_logs,
|
||||
audio_logs,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
use std::net::ToSocketAddrs;
|
||||
use std::path::Path;
|
||||
use std::time::Instant;
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct NetworkDeepScanResult {
|
||||
pub dns_broken_symlink: bool,
|
||||
pub dns_servers: Vec<String>,
|
||||
pub dns_resolution_ok: bool,
|
||||
pub dns_latency_ms: Option<u128>,
|
||||
pub has_default_gateway: bool,
|
||||
pub default_gateway_ip: Option<String>,
|
||||
pub firewall_conflict: Option<String>,
|
||||
pub wifi_drop_events: Vec<String>,
|
||||
}
|
||||
|
||||
pub struct NetworkDeepScanner;
|
||||
|
||||
impl NetworkDeepScanner {
|
||||
pub fn scan() -> NetworkDeepScanResult {
|
||||
let mut res = NetworkDeepScanResult::default();
|
||||
|
||||
// 1. Проверка /etc/resolv.conf
|
||||
Self::check_resolv_conf(&mut res);
|
||||
|
||||
// 2. Замер DNS задержки
|
||||
Self::measure_dns(&mut res);
|
||||
|
||||
// 3. Проверка шлюза по умолчанию
|
||||
Self::check_default_gateway(&mut res);
|
||||
|
||||
// 4. Конфликты брандмауэров
|
||||
res.firewall_conflict = Self::check_firewall_conflicts();
|
||||
|
||||
// 5. Поиск сбросов Wi-Fi
|
||||
res.wifi_drop_events = Self::scan_wifi_drops();
|
||||
|
||||
result_post_process(res)
|
||||
}
|
||||
|
||||
fn check_resolv_conf(res: &mut NetworkDeepScanResult) {
|
||||
let resolv = Path::new("/etc/resolv.conf");
|
||||
if resolv.is_symlink() {
|
||||
if let Ok(target) = std::fs::read_link(resolv) {
|
||||
if !target.exists() && !resolv.exists() {
|
||||
res.dns_broken_symlink = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if let Ok(content) = std::fs::read_to_string(resolv) {
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("nameserver") {
|
||||
let parts: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
if parts.len() >= 2 {
|
||||
res.dns_servers.push(parts[1].to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if res.dns_servers.is_empty() {
|
||||
if let Ok(output) = std::process::Command::new("scutil").arg("--dns").output() {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("nameserver[0] :") || trimmed.starts_with("nameserver[1] :") {
|
||||
if let Some(ip) = trimmed.split(':').nth(1) {
|
||||
let ip_clean = ip.trim().to_string();
|
||||
if !res.dns_servers.contains(&ip_clean) {
|
||||
res.dns_servers.push(ip_clean);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if res.dns_servers.is_empty() {
|
||||
let ps_cmd = "Get-DnsClientServerAddress -AddressFamily IPv4 | Select-Object -ExpandProperty ServerAddresses";
|
||||
if let Ok(output) = std::process::Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd]).output() {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
let ip = line.trim().to_string();
|
||||
if !ip.is_empty() && !res.dns_servers.contains(&ip) {
|
||||
res.dns_servers.push(ip);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn measure_dns(res: &mut NetworkDeepScanResult) {
|
||||
let start = Instant::now();
|
||||
// Пытаемся зарезолвить надежный домен
|
||||
match ("cloudflare.com", 80).to_socket_addrs() {
|
||||
Ok(_) => {
|
||||
res.dns_resolution_ok = true;
|
||||
res.dns_latency_ms = Some(start.elapsed().as_millis());
|
||||
}
|
||||
Err(_) => {
|
||||
// Вторая попытка
|
||||
match ("1.1.1.1", 80).to_socket_addrs() {
|
||||
Ok(_) => {
|
||||
res.dns_resolution_ok = false; // IP работает, а доменное имя нет -> DNS сломан!
|
||||
}
|
||||
Err(_) => {
|
||||
res.dns_resolution_ok = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_default_gateway(res: &mut NetworkDeepScanResult) {
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/net/route") {
|
||||
for line in content.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
// If destination is 00000000, it's default gateway
|
||||
if parts.len() >= 3 && parts[1] == "00000000" {
|
||||
res.has_default_gateway = true;
|
||||
if let Ok(hex_ip) = u32::from_str_radix(parts[2], 16) {
|
||||
let ip = std::net::Ipv4Addr::from(hex_ip.to_be());
|
||||
res.default_gateway_ip = Some(ip.to_string());
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
if !res.has_default_gateway {
|
||||
if let Ok(output) = std::process::Command::new("route").args(["-n", "get", "default"]).output() {
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("gateway:") {
|
||||
if let Some(gw) = trimmed.split(':').nth(1) {
|
||||
res.has_default_gateway = true;
|
||||
res.default_gateway_ip = Some(gw.trim().to_string());
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
if !res.has_default_gateway {
|
||||
let ps_cmd = "Get-NetRoute -DestinationPrefix '0.0.0.0/0' | Select-Object -ExpandProperty NextHop";
|
||||
if let Ok(output) = std::process::Command::new("powershell").args(["-NoProfile", "-NonInteractive", "-Command", ps_cmd]).output() {
|
||||
let gw = String::from_utf8_lossy(&output.stdout).trim().to_string();
|
||||
if !gw.is_empty() {
|
||||
res.has_default_gateway = true;
|
||||
res.default_gateway_ip = Some(gw);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn check_firewall_conflicts() -> Option<String> {
|
||||
let ufw_active = Path::new("/run/ufw").exists() || Path::new("/etc/ufw").exists();
|
||||
let firewalld_active = Path::new("/run/firewalld").exists();
|
||||
|
||||
if ufw_active && firewalld_active {
|
||||
return Some("Обнаружены одновременно активные службы UFW и firewalld. Это может вызывать сброс соединений.".to_string());
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn scan_wifi_drops() -> Vec<String> {
|
||||
let mut drops = Vec::new();
|
||||
// Ищем в journalctl события деаутентификации wpa_supplicant или iwd
|
||||
if let Ok(output) = crate::auth::PrivilegeManager::exec_privileged(
|
||||
"journalctl",
|
||||
&["-u", "wpa_supplicant", "-u", "iwd", "-n", "30", "--no-pager"],
|
||||
) {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let lower = line.to_lowercase();
|
||||
if lower.contains("deauthenticated") || lower.contains("connection lost") || lower.contains("beacon loss") {
|
||||
drops.push(line.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
drops
|
||||
}
|
||||
}
|
||||
|
||||
fn result_post_process(mut res: NetworkDeepScanResult) -> NetworkDeepScanResult {
|
||||
if res.dns_servers.is_empty() {
|
||||
res.dns_resolution_ok = false;
|
||||
}
|
||||
res
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct PsiMetrics {
|
||||
pub cpu_some_avg10: f32,
|
||||
pub mem_some_avg10: f32,
|
||||
pub mem_full_avg10: f32,
|
||||
pub io_some_avg10: f32,
|
||||
pub io_full_avg10: f32,
|
||||
pub is_supported: bool,
|
||||
}
|
||||
|
||||
pub struct PressureScanner;
|
||||
|
||||
impl PressureScanner {
|
||||
pub fn scan() -> PsiMetrics {
|
||||
let mut psi = PsiMetrics::default();
|
||||
|
||||
// 1. /proc/pressure/cpu
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/pressure/cpu") {
|
||||
psi.is_supported = true;
|
||||
psi.cpu_some_avg10 = Self::extract_avg10(&content, "some");
|
||||
}
|
||||
|
||||
// 2. /proc/pressure/memory
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/pressure/memory") {
|
||||
psi.is_supported = true;
|
||||
psi.mem_some_avg10 = Self::extract_avg10(&content, "some");
|
||||
psi.mem_full_avg10 = Self::extract_avg10(&content, "full");
|
||||
}
|
||||
|
||||
// 3. /proc/pressure/io
|
||||
if let Ok(content) = std::fs::read_to_string("/proc/pressure/io") {
|
||||
psi.is_supported = true;
|
||||
psi.io_some_avg10 = Self::extract_avg10(&content, "some");
|
||||
psi.io_full_avg10 = Self::extract_avg10(&content, "full");
|
||||
}
|
||||
|
||||
psi
|
||||
}
|
||||
|
||||
fn extract_avg10(content: &str, line_prefix: &str) -> f32 {
|
||||
for line in content.lines() {
|
||||
if line.starts_with(line_prefix) {
|
||||
for part in line.split_whitespace() {
|
||||
if let Some(val_str) = part.strip_prefix("avg10=") {
|
||||
return val_str.parse::<f32>().unwrap_or(0.0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
0.0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,364 @@
|
||||
use crate::models::{
|
||||
ListeningPortInfo, SecurityAuditReport, SecurityCheckItem, Severity, SuidAnomalyInfo,
|
||||
};
|
||||
use std::fs;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::Path;
|
||||
use std::process::Command;
|
||||
|
||||
pub struct SecurityScanner;
|
||||
|
||||
impl SecurityScanner {
|
||||
pub fn scan() -> SecurityAuditReport {
|
||||
let mut report = SecurityAuditReport::default();
|
||||
|
||||
Self::scan_listening_ports(&mut report);
|
||||
Self::scan_suid_anomalies(&mut report);
|
||||
Self::scan_hardening_checks(&mut report);
|
||||
Self::calculate_score(&mut report);
|
||||
|
||||
report
|
||||
}
|
||||
|
||||
fn scan_listening_ports(report: &mut SecurityAuditReport) {
|
||||
// 1. Используем утилиту ss -tulpn (или netstat)
|
||||
if let Ok(output) = Command::new("ss").args(["-tulpn", "-H"]).output() {
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
for line in stdout.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() >= 5 {
|
||||
let proto = parts[0].to_ascii_uppercase();
|
||||
let local_addr = parts[4];
|
||||
|
||||
// Разбираем IP и порт (например: 0.0.0.0:22 или [::]:80 или 127.0.0.1:6379)
|
||||
if let Some(colon_idx) = local_addr.rfind(':') {
|
||||
let ip = &local_addr[..colon_idx];
|
||||
let port_str = &local_addr[colon_idx + 1..];
|
||||
if let Ok(port) = port_str.parse::<u16>() {
|
||||
let is_public = ip == "0.0.0.0" || ip == "*" || ip == "::" || ip == "[::]";
|
||||
|
||||
// Извлекаем имя процесса и PID из последней колонки: users:(("sshd",pid=123,fd=3))
|
||||
let proc_info = parts.get(6).or_else(|| parts.get(5)).unwrap_or(&"");
|
||||
let (proc_name, pid) = parse_process_info(proc_info);
|
||||
|
||||
let (risk_level, rec) = assess_port_risk(port, is_public, &proc_name);
|
||||
|
||||
report.exposed_ports.push(ListeningPortInfo {
|
||||
protocol: proto,
|
||||
local_address: local_addr.to_string(),
|
||||
port,
|
||||
process_name: proc_name,
|
||||
pid,
|
||||
is_public,
|
||||
risk_level,
|
||||
recommendation: rec,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback через чтение /proc/net/tcp
|
||||
Self::scan_proc_net_tcp(report);
|
||||
}
|
||||
|
||||
// Сортировка: сначала критические и публичные порты
|
||||
report.exposed_ports.sort_by(|a, b| {
|
||||
let rank = |sev: &Severity| match sev {
|
||||
Severity::Critical => 0,
|
||||
Severity::Warning => 1,
|
||||
Severity::Info => 2,
|
||||
};
|
||||
rank(&a.risk_level).cmp(&rank(&b.risk_level)).then(b.is_public.cmp(&a.is_public))
|
||||
});
|
||||
}
|
||||
|
||||
fn scan_proc_net_tcp(report: &mut SecurityAuditReport) {
|
||||
if let Ok(content) = fs::read_to_string("/proc/net/tcp") {
|
||||
for line in content.lines().skip(1) {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
// State 0A = TCP_LISTEN
|
||||
if parts.len() >= 4 && parts[3] == "0A" {
|
||||
if let Some(local) = parts.get(1) {
|
||||
if let Some((_, port)) = parse_hex_socket(local) {
|
||||
let is_public = local.starts_with("00000000:");
|
||||
let (risk_level, rec) = assess_port_risk(port, is_public, "tcp-service");
|
||||
report.exposed_ports.push(ListeningPortInfo {
|
||||
protocol: "TCP".to_string(),
|
||||
local_address: format!("*:{}", port),
|
||||
port,
|
||||
process_name: "unknown".to_string(),
|
||||
pid: None,
|
||||
is_public,
|
||||
risk_level,
|
||||
recommendation: rec,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_suid_anomalies(report: &mut SecurityAuditReport) {
|
||||
// Проверяем директории, где SUID-бинарников быть не должно: /tmp, /var/tmp, /dev/shm
|
||||
let suspect_dirs = ["/tmp", "/var/tmp", "/dev/shm"];
|
||||
for dir in suspect_dirs {
|
||||
let path = Path::new(dir);
|
||||
if path.exists() {
|
||||
if let Ok(entries) = fs::read_dir(path) {
|
||||
for entry in entries.flatten() {
|
||||
let p = entry.path();
|
||||
if let Ok(meta) = p.metadata() {
|
||||
let mode = meta.permissions().mode();
|
||||
// Бит SUID: 0o4000, SGID: 0o2000
|
||||
if mode & 0o4000 != 0 || mode & 0o2000 != 0 {
|
||||
report.suid_anomalies.push(SuidAnomalyInfo {
|
||||
path: p.to_string_lossy().to_string(),
|
||||
owner: "root".to_string(),
|
||||
permissions: format!("{:o}", mode & 0o7777),
|
||||
risk_reason: format!("SUID/SGID файл обнаружен во временном каталоге {}. Потенциальный вектор эскалации привилегий.", dir),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn scan_hardening_checks(report: &mut SecurityAuditReport) {
|
||||
// 1. ASLR (randomize_va_space)
|
||||
let aslr = fs::read_to_string("/proc/sys/kernel/randomize_va_space")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let aslr_ok = aslr == "2";
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "Ядро".to_string(),
|
||||
name: "ASLR (Address Space Layout Randomization)".to_string(),
|
||||
status: if aslr_ok { "Активен (Full)" } else { "Уязвим / Отключен" }.to_string(),
|
||||
is_secure: aslr_ok,
|
||||
description: "Случайное распределение адресного пространства защищает от переполнения буфера.".to_string(),
|
||||
remediation: if aslr_ok { None } else { Some("sysctl -w kernel.randomize_va_space=2".to_string()) },
|
||||
});
|
||||
|
||||
// 2. Ptrace Scope (Yama LSM)
|
||||
let ptrace = fs::read_to_string("/proc/sys/kernel/yama/ptrace_scope")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let ptrace_ok = ptrace == "1" || ptrace == "2" || ptrace == "3";
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "Ядро".to_string(),
|
||||
name: "Yama ptrace_scope (Инъекция кода в процессы)".to_string(),
|
||||
status: if ptrace_ok { "Защищен" } else { "Разрешена инъекция (0)" }.to_string(),
|
||||
is_secure: ptrace_ok,
|
||||
description: "Ограничивает возможность нерутовых процессов внедрять код в память других процессов через ptrace.".to_string(),
|
||||
remediation: if ptrace_ok { None } else { Some("sysctl -w kernel.yama.ptrace_scope=1".to_string()) },
|
||||
});
|
||||
|
||||
// 3. Dmesg Restrict
|
||||
let dmesg_res = fs::read_to_string("/proc/sys/kernel/dmesg_restrict")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let dmesg_ok = dmesg_res == "1";
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "Ядро".to_string(),
|
||||
name: "Защита буфера dmesg_restrict".to_string(),
|
||||
status: if dmesg_ok { "Защищен" } else { "Открыт всем" }.to_string(),
|
||||
is_secure: dmesg_ok,
|
||||
description: "Запрещает непривилегированным пользователям читать адреса ядра из dmesg.".to_string(),
|
||||
remediation: if dmesg_ok { None } else { Some("sysctl -w kernel.dmesg_restrict=1".to_string()) },
|
||||
});
|
||||
|
||||
// 4. Unprivileged BPF
|
||||
let bpf = fs::read_to_string("/proc/sys/kernel/unprivileged_bpf_disabled")
|
||||
.unwrap_or_default()
|
||||
.trim()
|
||||
.to_string();
|
||||
let bpf_ok = bpf == "1" || bpf == "2";
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "Ядро".to_string(),
|
||||
name: "Unprivileged eBPF".to_string(),
|
||||
status: if bpf_ok { "Заблокирован" } else { "Разрешен" }.to_string(),
|
||||
is_secure: bpf_ok,
|
||||
description: "Защищает ядро от Spectre-подобных атак через запуск eBPF обычными пользователями.".to_string(),
|
||||
remediation: if bpf_ok { None } else { Some("sysctl -w kernel.unprivileged_bpf_disabled=1".to_string()) },
|
||||
});
|
||||
|
||||
// 5. AppArmor / SELinux
|
||||
let apparmor_active = Path::new("/sys/kernel/security/apparmor").exists();
|
||||
let selinux_active = Path::new("/sys/fs/selinux").exists();
|
||||
let lsm_ok = apparmor_active || selinux_active;
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "LSM".to_string(),
|
||||
name: "Мандатный контроль доступа (LSM)".to_string(),
|
||||
status: if apparmor_active { "AppArmor активен" } else if selinux_active { "SELinux активен" } else { "LSM отключен" }.to_string(),
|
||||
is_secure: lsm_ok,
|
||||
description: "Ограничивает права демонов даже при компрометации root прав.".to_string(),
|
||||
remediation: if lsm_ok { None } else { Some("Включите AppArmor или SELinux в параметрах загрузки ядра.".to_string()) },
|
||||
});
|
||||
|
||||
// 6. Secure Boot
|
||||
let sb_active = Path::new("/sys/firmware/efi/efivars").exists();
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "Прошивка".to_string(),
|
||||
name: "UEFI Secure Boot".to_string(),
|
||||
status: if sb_active { "UEFI режим" } else { "Legacy BIOS" }.to_string(),
|
||||
is_secure: true,
|
||||
description: "Проверяет криптографическую подпись модулей ядра при загрузке.".to_string(),
|
||||
remediation: None,
|
||||
});
|
||||
|
||||
// 7. SSH Configuration
|
||||
Self::check_ssh_hardening(report);
|
||||
}
|
||||
|
||||
fn check_ssh_hardening(report: &mut SecurityAuditReport) {
|
||||
let ssh_paths = ["/etc/ssh/sshd_config", "/etc/sshd_config"];
|
||||
for path_str in ssh_paths {
|
||||
let path = Path::new(path_str);
|
||||
if path.exists() {
|
||||
if let Ok(content) = fs::read_to_string(path) {
|
||||
let mut root_login_ok = true;
|
||||
let mut empty_pass_ok = true;
|
||||
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with('#') {
|
||||
continue;
|
||||
}
|
||||
let lower = trimmed.to_lowercase();
|
||||
if lower.starts_with("permitrootlogin") && lower.contains("yes") {
|
||||
root_login_ok = false;
|
||||
}
|
||||
if lower.starts_with("permitemptypasswords") && lower.contains("yes") {
|
||||
empty_pass_ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "SSH".to_string(),
|
||||
name: "SSH PermitRootLogin".to_string(),
|
||||
status: if root_login_ok { "Защищен" } else { "Разрешен вход root" }.to_string(),
|
||||
is_secure: root_login_ok,
|
||||
description: "Прямой вход суперпользователя по SSH без sudo.".to_string(),
|
||||
remediation: if root_login_ok { None } else { Some("Установите 'PermitRootLogin prohibit-password' в /etc/ssh/sshd_config".to_string()) },
|
||||
});
|
||||
|
||||
report.hardening_checks.push(SecurityCheckItem {
|
||||
category: "SSH".to_string(),
|
||||
name: "SSH PermitEmptyPasswords".to_string(),
|
||||
status: if empty_pass_ok { "Запрещены" } else { "Разрешены пустые пароли!" }.to_string(),
|
||||
is_secure: empty_pass_ok,
|
||||
description: "Разрешение авторизации учетных записей без паролей.".to_string(),
|
||||
remediation: if empty_pass_ok { None } else { Some("Установите 'PermitEmptyPasswords no' в /etc/ssh/sshd_config".to_string()) },
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn calculate_score(report: &mut SecurityAuditReport) {
|
||||
let mut score: i32 = 100;
|
||||
|
||||
for port in &report.exposed_ports {
|
||||
if port.is_public {
|
||||
match port.risk_level {
|
||||
Severity::Critical => score -= 20,
|
||||
Severity::Warning => score -= 8,
|
||||
Severity::Info => score -= 2,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for _suid in &report.suid_anomalies {
|
||||
score -= 25;
|
||||
}
|
||||
|
||||
for check in &report.hardening_checks {
|
||||
if !check.is_secure {
|
||||
score -= 10;
|
||||
}
|
||||
}
|
||||
|
||||
report.security_score = score.clamp(10, 100) as u8;
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_process_info(text: &str) -> (String, Option<u32>) {
|
||||
if let Some(start) = text.find('"') {
|
||||
if let Some(end) = text[start + 1..].find('"') {
|
||||
let name = &text[start + 1..start + 1 + end];
|
||||
let pid = text.find("pid=").and_then(|idx| {
|
||||
let rest = &text[idx + 4..];
|
||||
let pid_str: String = rest.chars().take_while(|c| c.is_ascii_digit()).collect();
|
||||
pid_str.parse::<u32>().ok()
|
||||
});
|
||||
return (name.to_string(), pid);
|
||||
}
|
||||
}
|
||||
("unknown".to_string(), None)
|
||||
}
|
||||
|
||||
fn assess_port_risk(port: u16, is_public: bool, proc_name: &str) -> (Severity, String) {
|
||||
let lower_proc = proc_name.to_lowercase();
|
||||
match port {
|
||||
6379 if is_public => (
|
||||
Severity::Critical,
|
||||
"Redis открыт на 0.0.0.0! Риск неавторизованного RCE. Привяжите к 127.0.0.1 (bind 127.0.0.1).".to_string(),
|
||||
),
|
||||
27017 if is_public => (
|
||||
Severity::Critical,
|
||||
"MongoDB слушает на публичном интерфейсе. Ограничьте bindIp 127.0.0.1.".to_string(),
|
||||
),
|
||||
2375 | 2376 if is_public => (
|
||||
Severity::Critical,
|
||||
"Docker Daemon API открыт в публичную сеть без TLS. Любой клиент имеет root-доступ к хосту!".to_string(),
|
||||
),
|
||||
9200 if is_public => (
|
||||
Severity::Critical,
|
||||
"Elasticsearch HTTP порт доступен публично. Настройте аутентификацию и фаервол.".to_string(),
|
||||
),
|
||||
23 if is_public => (
|
||||
Severity::Critical,
|
||||
"Telnet передает пароли в открытом виде. Отключите службу и перейдите на SSH.".to_string(),
|
||||
),
|
||||
5432 if is_public => (
|
||||
Severity::Warning,
|
||||
"PostgreSQL слушает на всех интерфейсах. Убедитесь в надежности pg_hba.conf.".to_string(),
|
||||
),
|
||||
3306 if is_public => (
|
||||
Severity::Warning,
|
||||
"MySQL/MariaDB порт открыт публично. Привяжите к bind-address = 127.0.0.1 при локальной работе.".to_string(),
|
||||
),
|
||||
5900 if is_public => (
|
||||
Severity::Warning,
|
||||
"VNC сервер доступен публично. Рекомендуется доступ исключительно через SSH туннель.".to_string(),
|
||||
),
|
||||
22 if is_public => (
|
||||
Severity::Info,
|
||||
"SSH порт 22 открыт наружу. Рекомендуется защита fail2ban/sshguard и вход по ключам.".to_string(),
|
||||
),
|
||||
_ if is_public && (lower_proc.contains("redis") || lower_proc.contains("mongo")) => (
|
||||
Severity::Critical,
|
||||
"База данных слушает на публичном интерфейсе!".to_string(),
|
||||
),
|
||||
_ => (
|
||||
Severity::Info,
|
||||
"Порт слушает входящие соединения.".to_string(),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_hex_socket(hex: &str) -> Option<(std::net::Ipv4Addr, u16)> {
|
||||
let mut parts = hex.split(':');
|
||||
let ip_hex = parts.next()?;
|
||||
let port_hex = parts.next()?;
|
||||
let ip_num = u32::from_str_radix(ip_hex, 16).ok()?;
|
||||
let port = u16::from_str_radix(port_hex, 16).ok()?;
|
||||
Some((std::net::Ipv4Addr::from(ip_num.to_be()), port))
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
use std::ffi::CString;
|
||||
use std::mem::MaybeUninit;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MountHealth {
|
||||
pub mount_point: String,
|
||||
pub filesystem: String,
|
||||
pub total_bytes: u64,
|
||||
pub free_bytes: u64,
|
||||
pub used_percent: f32,
|
||||
pub inodes_total: u64,
|
||||
pub inodes_free: u64,
|
||||
pub inode_used_percent: f32,
|
||||
pub is_critical: bool,
|
||||
}
|
||||
|
||||
pub struct StorageScanner;
|
||||
|
||||
impl StorageScanner {
|
||||
pub fn scan() -> Vec<MountHealth> {
|
||||
let mut results = Vec::new();
|
||||
|
||||
if let Ok(mounts) = std::fs::read_to_string("/proc/mounts") {
|
||||
for line in mounts.lines() {
|
||||
let parts: Vec<&str> = line.split_whitespace().collect();
|
||||
if parts.len() < 3 {
|
||||
continue;
|
||||
}
|
||||
let device = parts[0];
|
||||
let mount_point = parts[1];
|
||||
let fstype = parts[2];
|
||||
|
||||
// Фильтруем виртуальные ФС, оставляем реальные блочные устройства / ZFS / BTRFS
|
||||
if !device.starts_with("/dev/") && fstype != "zfs" && fstype != "btrfs" {
|
||||
continue;
|
||||
}
|
||||
|
||||
if let Some(health) = Self::check_mount(mount_point, fstype) {
|
||||
results.push(health);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
results
|
||||
}
|
||||
|
||||
fn check_mount(mount_point: &str, fstype: &str) -> Option<MountHealth> {
|
||||
let c_path = CString::new(mount_point).ok()?;
|
||||
unsafe {
|
||||
let mut stat: MaybeUninit<libc::statvfs> = MaybeUninit::uninit();
|
||||
if libc::statvfs(c_path.as_ptr(), stat.as_mut_ptr()) == 0 {
|
||||
let stat = stat.assume_init();
|
||||
let bsize = if stat.f_frsize > 0 { stat.f_frsize } else { stat.f_bsize } as u64;
|
||||
let total_bytes = stat.f_blocks * bsize;
|
||||
let free_bytes = stat.f_bavail * bsize;
|
||||
let used_bytes = total_bytes.saturating_sub(free_bytes);
|
||||
|
||||
let used_percent = if total_bytes > 0 {
|
||||
(used_bytes as f32 / total_bytes as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let inodes_total = stat.f_files;
|
||||
let inodes_free = stat.f_ffree;
|
||||
let inodes_used = inodes_total.saturating_sub(inodes_free);
|
||||
let inode_used_percent = if inodes_total > 0 {
|
||||
(inodes_used as f32 / inodes_total as f32) * 100.0
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
|
||||
let is_critical = used_percent >= 90.0 || inode_used_percent >= 95.0;
|
||||
|
||||
Some(MountHealth {
|
||||
mount_point: mount_point.to_string(),
|
||||
filesystem: fstype.to_string(),
|
||||
total_bytes,
|
||||
free_bytes,
|
||||
used_percent,
|
||||
inodes_total,
|
||||
inodes_free,
|
||||
inode_used_percent,
|
||||
is_critical,
|
||||
})
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user