Skip to main content

edera_check/recorders/preinstall/
system.rs

1use anyhow::{Result, bail};
2use async_trait::async_trait;
3use flate2::read::GzDecoder;
4use futures::{FutureExt, future::join_all};
5use procfs::Current;
6use std::{
7    io::Read,
8    path::{Path, PathBuf},
9    process::Command,
10};
11
12use crate::helpers::{
13    CheckGroup, CheckGroupCategory, CheckGroupResult, CheckResult,
14    CheckResultValue::{Errored, Failed, Passed, Skipped},
15    host_executor::HostNamespaceExecutor,
16};
17
18const GROUP_IDENTIFIER: &str = "sysinfo";
19const NAME: &str = "System Info Recorder";
20
21pub struct SystemRecorder {
22    host_executor: HostNamespaceExecutor,
23}
24
25impl SystemRecorder {
26    pub fn new(host_executor: HostNamespaceExecutor) -> Self {
27        SystemRecorder { host_executor }
28    }
29
30    /// Run all the recorders asynchronously, then
31    /// join and collect the results.
32    pub async fn run_all(&self) -> CheckGroupResult {
33        let results = join_all([
34            self.record_lspci().boxed(),
35            self.record_dmidecode().boxed(),
36            self.record_cpuinfo().boxed(),
37            self.record_cmdline().boxed(),
38            self.record_grub_cfg().boxed(),
39            self.record_kernel_cfg().boxed(),
40            self.record_loaded_modules().boxed(),
41        ])
42        .await;
43
44        let mut group_result = Passed;
45        for res in results.iter() {
46            // Set group result to Failed if we failed and aren't already in an Errored state
47            if !matches!(group_result, Errored(_)) && matches!(res.result, Failed(_)) {
48                group_result = Failed(String::from("group failed"));
49            }
50
51            if matches!(res.result, Errored(_)) {
52                group_result = Errored(String::from("group errored"));
53            }
54        }
55
56        CheckGroupResult {
57            name: NAME.to_string(),
58            result: group_result,
59            results,
60        }
61    }
62
63    /// Runs the given command + args in host namespaces and captures the results.
64    async fn run_tool(&self, tool: &str) -> CheckResult {
65        let name = format!("Captured {tool}");
66        let mut tool_args: Vec<String> = tool.split(" ").map(|s| s.to_string()).collect();
67        let cmd = tool_args.remove(0);
68
69        let output = match self
70            .host_executor
71            .spawn_in_host_ns(async move { Command::new(cmd).args(tool_args).output() })
72            .await
73        {
74            Ok(output) => output,
75            Err(e) => return CheckResult::new(&name, Skipped(e.to_string())),
76        };
77
78        let output = match output {
79            Ok(output) => output,
80            Err(e) => return CheckResult::new(&name, Skipped(e.to_string())),
81        };
82
83        if !output.status.success() {
84            let error_message = String::from_utf8_lossy(&output.stderr);
85            return CheckResult::new(&name, Skipped(error_message.to_string()));
86        }
87
88        let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
89        CheckResult::new_with_output(&name, Passed, Some(stdout))
90    }
91
92    /// Captures the content of a given file on the host.
93    async fn record_file(&self, file: &Path) -> Option<CheckResult> {
94        let local_file = file.to_path_buf();
95
96        self.host_executor
97            .spawn_in_host_ns(async move {
98                if !local_file.exists() {
99                    return None;
100                }
101                let name = format!("Captured {}", local_file.display());
102
103                let bytes = match tokio::fs::read(&local_file).await {
104                    Ok(b) => b,
105                    Err(e) => {
106                        return Some(CheckResult::new(
107                            &name,
108                            Errored(format!("failed to read {}: {e}", local_file.display())),
109                        ));
110                    }
111                };
112
113                let content = if bytes.starts_with(&[0x1f, 0x8b]) {
114                    // Gzip magic bytes detected
115                    let mut decoder = GzDecoder::new(&bytes[..]);
116                    let mut s = String::new();
117                    decoder.read_to_string(&mut s).map(|_| s)
118                } else {
119                    String::from_utf8(bytes)
120                        .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
121                };
122
123                match content {
124                    Ok(c) => Some(CheckResult::new_with_output(&name, Passed, Some(c))),
125                    Err(e) => Some(CheckResult::new(
126                        &name,
127                        Errored(format!("failed to decode {}: {e}", local_file.display())),
128                    )),
129                }
130            })
131            .await
132            .unwrap_or_else(|_| panic!("could not record {}", file.display()))
133    }
134
135    /// Records verbose PCI device listing.
136    ///
137    /// Manual equivalent:
138    /// ```sh
139    /// lspci -vvv
140    /// ```
141    pub async fn record_lspci(&self) -> CheckResult {
142        self.run_tool("lspci -vvv").await
143    }
144
145    /// Records DMI/SMBIOS hardware information (BIOS, board, chassis, CPU, memory).
146    ///
147    /// Manual equivalent:
148    /// ```sh
149    /// dmidecode
150    /// ```
151    pub async fn record_dmidecode(&self) -> CheckResult {
152        self.run_tool("dmidecode").await
153    }
154
155    /// Records CPU hardware details and feature flags.
156    ///
157    /// Manual equivalent:
158    /// ```sh
159    /// cat /proc/cpuinfo
160    /// ```
161    pub async fn record_cpuinfo(&self) -> CheckResult {
162        self.record_file(PathBuf::from("/proc/cpuinfo").as_ref())
163            .await
164            .expect("/proc/cpuinfo not found")
165    }
166
167    /// Records the kernel command line used to boot the running kernel.
168    ///
169    /// Manual equivalent:
170    /// ```sh
171    /// cat /proc/cmdline
172    /// ```
173    pub async fn record_cmdline(&self) -> CheckResult {
174        self.record_file(PathBuf::from("/proc/cmdline").as_ref())
175            .await
176            .expect("/proc/cmdline not found")
177    }
178
179    /// Records the GRUB bootloader configuration. Checks `/boot/grub2/grub.cfg` first,
180    /// falling back to `/boot/grub/grub.cfg`.
181    ///
182    /// Manual equivalent:
183    /// ```sh
184    /// cat /boot/grub2/grub.cfg || cat /boot/grub/grub.cfg
185    /// ```
186    pub async fn record_grub_cfg(&self) -> CheckResult {
187        // prefer grub2 path, since if both are present for any reason,
188        // that is likely to be the "correct" one.
189        let files = ["/boot/grub2/grub.cfg", "/boot/grub/grub.cfg"];
190
191        for file in files {
192            if let Some(result) = self.record_file(&PathBuf::from(file)).await {
193                return result;
194            }
195        }
196        CheckResult::new(
197            "Record grub config",
198            Skipped(format!("no grub config found in {:?}", files)),
199        )
200    }
201
202    /// Records the kernel build configuration. Checks `/proc/config.gz` first (decompressing
203    /// if needed), falling back to `/boot/config-$(uname -r)`.
204    ///
205    /// Manual equivalent:
206    /// ```sh
207    /// zcat /proc/config.gz || cat /boot/config-$(uname -r)
208    /// ```
209    pub async fn record_kernel_cfg(&self) -> CheckResult {
210        let name = "Record kernel config";
211        // Get kernel version
212        //
213        let Ok(kver) = self.current_kernel_version().await else {
214            return CheckResult::new(name, Errored("failed to find kernel version".to_string()));
215        };
216
217        let files = ["/proc/config.gz", &format!("boot/config-{kver}")];
218
219        for file in files {
220            if let Some(result) = self.record_file(&PathBuf::from(file)).await {
221                return result;
222            }
223        }
224        CheckResult::new(
225            name,
226            Skipped(format!("no kernel config found in {:?}", files)),
227        )
228    }
229
230    /// Records the list of currently loaded kernel modules.
231    ///
232    /// Manual equivalent:
233    /// ```sh
234    /// cut -d' ' -f1 /proc/modules
235    /// ```
236    pub async fn record_loaded_modules(&self) -> CheckResult {
237        let name = "Record current host kernel loaded modules";
238        match self
239            .host_executor
240            .spawn_in_host_ns(async move { procfs::KernelModules::current() })
241            .await
242        {
243            Ok(Ok(list)) => CheckResult::new_with_output(
244                name,
245                Passed,
246                Some(list.0.into_keys().collect::<Vec<_>>().join("\n")),
247            ),
248            Ok(Err(e)) => CheckResult::new(name, Errored(e.to_string())),
249            Err(e) => CheckResult::new(name, Skipped(e.to_string())),
250        }
251    }
252
253    async fn current_kernel_version(&self) -> Result<String> {
254        self.host_executor
255            .spawn_in_host_ns(async {
256                let output = Command::new("uname")
257                    .arg("-r")
258                    .output()
259                    .expect("Failed to execute command");
260
261                if !output.status.success() {
262                    let error_message = String::from_utf8_lossy(&output.stderr);
263                    bail!("{}", error_message);
264                }
265
266                Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
267            })
268            .await?
269    }
270}
271
272#[async_trait]
273impl CheckGroup for SystemRecorder {
274    fn id(&self) -> &str {
275        GROUP_IDENTIFIER
276    }
277
278    fn name(&self) -> &str {
279        NAME
280    }
281
282    fn description(&self) -> &str {
283        "Record system information for reporting purposes"
284    }
285
286    async fn run(&self) -> CheckGroupResult {
287        self.run_all().await
288    }
289
290    fn category(&self) -> CheckGroupCategory {
291        CheckGroupCategory::Advisory
292    }
293}