feat: initial commit of diagd - unified Linux/BSD/macOS/Windows diagnostic tool (TUI + GUI)

This commit is contained in:
2026-09-13 18:29:47 +03:00
commit c3a9569918
165 changed files with 31940 additions and 0 deletions
+16
View File
@@ -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"
+810
View File
@@ -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,
})
}
+224
View File
@@ -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);
}
}
+466
View File
@@ -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
}
}
+38
View File
@@ -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());
}
}
+270
View File
@@ -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,
}
+75
View File
@@ -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: &regex::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]"));
}
}
+95
View File
@@ -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,
}
}
}
+213
View File
@@ -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
+59
View File
@@ -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)
}
}
+91
View File
@@ -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)
}
}
+73
View File
@@ -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());
}
}
+83
View File
@@ -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)
}
}
+35
View File
@@ -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>;
}
+87
View File
@@ -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)
}
}
+84
View File
@@ -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)
}
}
+111
View File
@@ -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
}
}
+59
View File
@@ -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
}
}
+86
View File
@@ -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,
}
}
}
+202
View File
@@ -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));
}
}
}
}
}
+114
View File
@@ -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,
}
}
}
+199
View File
@@ -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
}
+52
View File
@@ -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
}
}
+364
View File
@@ -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))
}
+91
View File
@@ -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
}
}
}
}