use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use appliance_pid1::net::{align4, netlink_message, netlink_socket};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct CpuTicks {
pub busy: u64,
pub total: u64,
}
impl CpuTicks {
pub fn busy_percent_since(self, earlier: CpuTicks) -> Option<u8> {
let total = self.total.checked_sub(earlier.total)?;
if total == 0 {
return None;
}
let busy = self.busy.saturating_sub(earlier.busy);
Some((busy * 100 / total).min(100) as u8)
}
}
pub fn parse_stat(text: &str) -> (Option<CpuTicks>, usize) {
let mut ticks = None;
let mut cpus = 0;
for line in text.lines() {
let mut parts = line.split_ascii_whitespace();
let Some(name) = parts.next() else { continue };
if name == "cpu" {
let v: Vec<u64> = parts.filter_map(|p| p.parse().ok()).collect();
if v.len() >= 4 {
let total: u64 = v.iter().sum();
let idle = v[3] + v.get(4).copied().unwrap_or(0);
ticks = Some(CpuTicks {
busy: total.saturating_sub(idle),
total,
});
}
} else if name.starts_with("cpu") && name[3..].chars().all(|c| c.is_ascii_digit()) {
cpus += 1;
}
}
(ticks, cpus)
}
pub fn parse_meminfo(text: &str) -> Option<(u64, u64)> {
let mut total = None;
let mut avail = None;
for line in text.lines() {
let (key, rest) = line.split_once(':')?;
let kib: u64 = rest.split_ascii_whitespace().next()?.parse().ok()?;
match key {
"MemTotal" => total = Some(kib * 1024),
"MemAvailable" => avail = Some(kib * 1024),
_ => {}
}
if total.is_some() && avail.is_some() {
break;
}
}
Some((total?, avail?))
}
pub fn parse_uptime(text: &str) -> Option<u64> {
let first = text.split_ascii_whitespace().next()?;
let secs: f64 = first.parse().ok()?;
Some(secs as u64)
}
pub fn parse_loadavg(text: &str) -> Option<String> {
text.split_ascii_whitespace().next().map(str::to_owned)
}
pub fn parse_cpu_model(text: &str) -> Option<String> {
let line = text.lines().find(|l| l.starts_with("model name"))?;
let (_, value) = line.split_once(':')?;
let mut out = String::new();
for word in value.split_ascii_whitespace() {
let w = word
.replace("(R)", "")
.replace("(TM)", "")
.replace("(tm)", "");
if w.is_empty()
|| w == "CPU"
|| w == "Processor"
|| w == "@"
|| w.ends_with("GHz")
|| is_core_count(&w)
{
continue;
}
if !out.is_empty() {
out.push(' ');
}
out.push_str(&w);
}
if out.is_empty() {
None
} else {
Some(out)
}
}
fn is_core_count(word: &str) -> bool {
let Some(digits) = word
.strip_suffix("-Cores")
.or_else(|| word.strip_suffix("-Core"))
else {
return false;
};
!digits.is_empty() && digits.chars().all(|c| c.is_ascii_digit())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MountFacts {
pub source: String,
pub fstype: String,
}
pub fn parse_mounts(text: &str, mountpoint: &str) -> Option<MountFacts> {
let mut found = None;
for line in text.lines() {
let mut f = line.split_ascii_whitespace();
let (Some(source), Some(target), Some(fstype)) = (f.next(), f.next(), f.next()) else {
continue;
};
if unescape_mount(target) == mountpoint {
found = Some(MountFacts {
source: unescape_mount(source),
fstype: fstype.to_owned(),
});
}
}
found
}
fn unescape_mount(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
if b[i] == b'\\'
&& i + 3 < b.len()
&& b[i + 1..i + 4].iter().all(|c| (b'0'..=b'7').contains(c))
{
let v = (b[i + 1] - b'0') * 64 + (b[i + 2] - b'0') * 8 + (b[i + 3] - b'0');
out.push(v);
i += 4;
} else {
out.push(b[i]);
i += 1;
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SetFacts {
pub level: String,
pub members: usize,
pub member_bytes: u64,
pub chunk_bytes: u64,
}
pub fn read_set_facts(block_dir: &std::path::Path) -> Option<SetFacts> {
let md = block_dir.join("md");
let text = |name: &str| {
std::fs::read_to_string(md.join(name))
.ok()
.map(|t| t.trim().to_owned())
};
let level = text("level").filter(|l| !l.is_empty())?;
let members: usize = text("raid_disks")?.parse().ok().filter(|&n| n > 0)?;
let chunk_bytes: u64 = text("chunk_size").and_then(|c| c.parse().ok()).unwrap_or(0);
let mut smallest: Option<u64> = None;
for entry in std::fs::read_dir(&md).ok()? {
let Ok(entry) = entry else { continue };
let name = entry.file_name();
let Some(dev) = name.to_str().and_then(|n| n.strip_prefix("dev-")) else {
continue;
};
let kib = std::fs::read_to_string(entry.path().join("size"))
.ok()
.and_then(|t| t.trim().parse::<u64>().ok())
.map(|k| k * 1024)
.or_else(|| {
std::fs::read_to_string(block_dir.join("../../class/block").join(dev).join("size"))
.ok()
.and_then(|t| t.trim().parse::<u64>().ok())
.map(|s| s * 512)
});
if let Some(b) = kib.filter(|&b| b > 0) {
smallest = Some(smallest.map_or(b, |s| s.min(b)));
}
}
Some(SetFacts {
level,
members,
member_bytes: smallest?,
chunk_bytes,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DiskKind {
pub fs: String,
pub set: Option<SetFacts>,
}
impl DiskKind {
pub fn from_mount(mount: &MountFacts, set: Option<SetFacts>) -> Self {
let fs = match mount.fstype.as_str() {
"vfat" | "msdos" | "fat" => "fat".to_owned(),
other => other.to_owned(),
};
DiskKind { fs, set }
}
}
impl std::fmt::Display for DiskKind {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if let Some(set) = &self.set {
write!(
f,
"{}\u{d7}{} {} ",
set.members,
crate::dashboard::fmt_bytes(set.member_bytes),
set.level
)?;
}
f.write_str(&self.fs)
}
}
fn sysfs_block_dir_of(source: &str) -> Option<std::path::PathBuf> {
use std::os::unix::fs::{FileTypeExt as _, MetadataExt as _};
let meta = std::fs::metadata(source).ok()?;
if !meta.file_type().is_block_device() {
return None;
}
let rdev = meta.rdev();
let dir = std::path::PathBuf::from(format!(
"/sys/dev/block/{}:{}",
rustix::fs::major(rdev),
rustix::fs::minor(rdev)
));
dir.is_dir().then_some(dir)
}
pub fn disk_kind(mountpoint: &std::path::Path) -> Option<DiskKind> {
let mount = parse_mounts(&read("/proc/mounts")?, mountpoint.to_str()?)?;
let set = sysfs_block_dir_of(&mount.source).and_then(|d| read_set_facts(&d));
Some(DiskKind::from_mount(&mount, set))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IfAddr {
pub interface: String,
pub address: IpAddr,
pub prefix: u8,
}
impl std::fmt::Display for IfAddr {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} {}/{}", self.interface, self.address, self.prefix)
}
}
const RTM_GETADDR: u16 = 22;
const RTM_NEWADDR: u16 = 20;
const NLMSG_DONE: u16 = 3;
const NLMSG_ERROR: u16 = 2;
const NLM_F_REQUEST: u16 = 0x0001;
const NLM_F_DUMP: u16 = 0x0300;
const NLM_F_MULTI: u16 = 0x0002;
const AF_INET: u8 = 2;
const AF_INET6: u8 = 10;
const IFA_ADDRESS: u16 = 1;
const IFA_LOCAL: u16 = 2;
const IFA_LABEL: u16 = 3;
const RT_SCOPE_HOST: u8 = 254;
const RT_SCOPE_LINK: u8 = 253;
pub fn getaddr_dump_message(seq: u32) -> Vec<u8> {
netlink_message(RTM_GETADDR, NLM_F_REQUEST | NLM_F_DUMP, seq, &[0u8; 8])
}
pub fn parse_getaddr_reply(
buf: &[u8],
resolve: &mut dyn FnMut(u32) -> Option<String>,
) -> (Vec<IfAddr>, bool) {
let mut out = Vec::new();
let mut off = 0usize;
let mut done = false;
while off + 16 <= buf.len() {
let len = u32::from_le_bytes([buf[off], buf[off + 1], buf[off + 2], buf[off + 3]]) as usize;
let kind = u16::from_le_bytes([buf[off + 4], buf[off + 5]]);
let flags = u16::from_le_bytes([buf[off + 6], buf[off + 7]]);
if len < 16 || off + len > buf.len() {
break;
}
match kind {
NLMSG_DONE => {
done = true;
break;
}
NLMSG_ERROR => {
done = true;
break;
}
RTM_NEWADDR if len >= 24 => {
let body = &buf[off + 16..off + len];
let family = body[0];
let prefix = body[1];
let scope = body[3];
let index = u32::from_le_bytes([body[4], body[5], body[6], body[7]]);
if scope != RT_SCOPE_HOST && scope != RT_SCOPE_LINK {
let mut address = None;
let mut local = None;
let mut label = None;
let mut a = 8usize;
while a + 4 <= body.len() {
let alen = u16::from_le_bytes([body[a], body[a + 1]]) as usize;
let atype = u16::from_le_bytes([body[a + 2], body[a + 3]]);
if alen < 4 || a + alen > body.len() {
break;
}
let payload = &body[a + 4..a + alen];
match atype {
IFA_ADDRESS => address = ip_from(family, payload),
IFA_LOCAL => local = ip_from(family, payload),
IFA_LABEL => {
let end = payload
.iter()
.position(|&b| b == 0)
.unwrap_or(payload.len());
label = String::from_utf8(payload[..end].to_vec()).ok();
}
_ => {}
}
a += align4(alen);
}
if let Some(ip) = local.or(address) {
let interface = label
.or_else(|| resolve(index))
.unwrap_or_else(|| format!("if{index}"));
out.push(IfAddr {
interface,
address: ip,
prefix,
});
}
}
}
_ => {}
}
if flags & NLM_F_MULTI == 0 {
done = true;
}
off += align4(len);
}
(out, done)
}
fn ip_from(family: u8, payload: &[u8]) -> Option<IpAddr> {
match (family, payload.len()) {
(AF_INET, 4) => Some(IpAddr::V4(Ipv4Addr::new(
payload[0], payload[1], payload[2], payload[3],
))),
(AF_INET6, 16) => {
let mut b = [0u8; 16];
b.copy_from_slice(payload);
Some(IpAddr::V6(Ipv6Addr::from(b)))
}
_ => None,
}
}
pub fn addresses() -> Vec<IfAddr> {
use rustix::net::{
netdevice, recv, send, socket, AddressFamily, RecvFlags, SendFlags, SocketType,
};
let Ok(sock) = netlink_socket() else {
return Vec::new();
};
if send(&sock, &getaddr_dump_message(1), SendFlags::empty()).is_err() {
return Vec::new();
}
let probe = socket(AddressFamily::INET, SocketType::DGRAM, None).ok();
let mut resolve = |index: u32| -> Option<String> {
let probe = probe.as_ref()?;
netdevice::index_to_name(probe, index).ok()
};
let mut out = Vec::new();
let mut buf = vec![0u8; 16384];
for _ in 0..64 {
let Ok((n, _)) = recv(&sock, &mut buf[..], RecvFlags::empty()) else {
break;
};
if n == 0 {
break;
}
let (mut got, done) = parse_getaddr_reply(&buf[..n], &mut resolve);
out.append(&mut got);
if done {
break;
}
}
out
}
fn read(path: &str) -> Option<String> {
std::fs::read_to_string(path).ok()
}
pub fn hostname() -> Option<String> {
let h = read("/proc/sys/kernel/hostname")?;
let h = h.trim();
if h.is_empty() || h == "(none)" {
None
} else {
Some(h.to_owned())
}
}
pub fn cpu_ticks() -> (Option<CpuTicks>, usize) {
read("/proc/stat")
.map(|t| parse_stat(&t))
.unwrap_or((None, 0))
}
pub fn memory() -> Option<(u64, u64)> {
meminfo().ok()
}
pub fn meminfo() -> Result<(u64, u64), String> {
let text = read_why("/proc/meminfo")?;
parse_meminfo(&text)
.ok_or_else(|| "/proc/meminfo has no MemTotal and MemAvailable lines to parse".to_owned())
}
pub fn load1_value() -> Result<f64, String> {
let text = read_why("/proc/loadavg")?;
parse_loadavg(&text)
.and_then(|first| first.parse::<f64>().ok())
.ok_or_else(|| "/proc/loadavg does not start with a number".to_owned())
}
fn read_why(path: &str) -> Result<String, String> {
std::fs::read_to_string(path).map_err(|err| format!("{path} could not be read ({err})"))
}
pub fn uptime_secs() -> Option<u64> {
parse_uptime(&read("/proc/uptime")?)
}
pub fn load1() -> Option<String> {
parse_loadavg(&read("/proc/loadavg")?)
}
pub fn cpu_model() -> Option<String> {
parse_cpu_model(&read("/proc/cpuinfo")?)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct IndexFacts {
pub git_dir: std::path::PathBuf,
pub snap: crate::storage::IndexSnapshot,
}
pub fn index_progress(engine: crate::storage::Engine) -> Vec<IndexFacts> {
engine
.index_progress()
.into_iter()
.map(|(git_dir, snap)| IndexFacts { git_dir, snap })
.collect()
}
pub fn disk(path: &std::path::Path) -> Option<(u64, u64)> {
let st = rustix::fs::statfs(path).ok()?;
let bsize = st.f_frsize as u64;
Some((st.f_blocks * bsize, st.f_bavail * bsize))
}
#[cfg(test)]
mod tests {
use super::*;
use appliance_pid1::net::rtattr;
const STAT: &str = "cpu 4705 0 3540 178540 320 0 87 0 0 0\ncpu0 2000 0 1700 89000 200 0 40 0 0 0\ncpu1 2705 0 1840 89540 120 0 47 0 0 0\nintr 12345\nctxt 1\n";
#[test]
fn stat_counts_cpus_and_turns_two_reads_into_a_busy_percent() {
let (t, cpus) = parse_stat(STAT);
assert_eq!(cpus, 2);
let t = t.unwrap();
assert_eq!(t.total, 4705 + 3540 + 178540 + 320 + 87);
assert_eq!(t.busy, 4705 + 3540 + 87);
let later = CpuTicks {
busy: t.busy + 50,
total: t.total + 200,
};
assert_eq!(later.busy_percent_since(t), Some(25));
assert_eq!(
t.busy_percent_since(t),
None,
"an empty interval is no percentage"
);
assert_eq!(
t.busy_percent_since(later),
None,
"a wrapped/earlier read is refused, not negative"
);
}
#[test]
fn meminfo_gives_total_and_available_in_bytes() {
let text = "MemTotal: 16303748 kB\nMemFree: 1234 kB\nMemAvailable: 15000000 kB\nBuffers: 1 kB\n";
assert_eq!(
parse_meminfo(text),
Some((16303748 * 1024, 15000000 * 1024))
);
assert_eq!(
parse_meminfo("MemTotal: 1 kB\n"),
None,
"a file without MemAvailable is not guessed at"
);
}
#[test]
fn uptime_load_and_model_parse_the_real_shapes() {
assert_eq!(parse_uptime("350735.47 234388.90\n"), Some(350735));
assert_eq!(
parse_loadavg("0.08 0.03 0.01 1/412 12345\n"),
Some("0.08".into())
);
assert_eq!(parse_cpu_model("processor: 0\n"), None);
}
#[test]
fn the_cpu_model_is_trimmed_of_suffixes_that_are_not_the_chip_name() {
let table = [
("AMD EPYC 7542 32-Core Processor", "AMD EPYC 7542"),
(
"AMD Ryzen Threadripper PRO 3975WX 32-Cores",
"AMD Ryzen Threadripper PRO 3975WX",
),
(
"Intel(R) Xeon(R) CPU E5-2680 v4 @ 2.40GHz",
"Intel Xeon E5-2680 v4",
),
(
"Intel(R) Core(TM) i7-8650U CPU @ 1.90GHz",
"Intel Core i7-8650U",
),
(
"Intel(R) Xeon(R) Platinum 8375C CPU @ 2.90GHz",
"Intel Xeon Platinum 8375C",
),
("QEMU Virtual CPU version 2.5+", "QEMU Virtual version 2.5+"),
("ARM Cortex-A72", "ARM Cortex-A72"),
];
for (raw, want) in table {
let cpuinfo = format!("processor\t: 0\nvendor_id\t: x\nmodel name\t: {raw}\n");
assert_eq!(parse_cpu_model(&cpuinfo).as_deref(), Some(want), "{raw}");
}
assert!(
is_core_count("32-Core")
&& is_core_count("64-Cores")
&& !is_core_count("-Core")
&& !is_core_count("Cortex-A72")
);
}
fn newaddr(
family: u8,
prefix: u8,
scope: u8,
index: u32,
addr: &[u8],
label: Option<&str>,
) -> Vec<u8> {
let mut body = vec![family, prefix, 0, scope];
body.extend_from_slice(&index.to_le_bytes());
body.extend_from_slice(&rtattr(IFA_ADDRESS, addr));
if let Some(l) = label {
let mut z = l.as_bytes().to_vec();
z.push(0);
body.extend_from_slice(&rtattr(IFA_LABEL, &z));
}
netlink_message(RTM_NEWADDR, NLM_F_MULTI, 1, &body)
}
fn done() -> Vec<u8> {
netlink_message(NLMSG_DONE, NLM_F_MULTI, 1, &[0u8; 4])
}
#[test]
fn the_dump_request_is_a_request_plus_dump_over_an_empty_ifaddrmsg() {
let m = getaddr_dump_message(7);
assert_eq!(m.len(), 24);
assert_eq!(u16::from_le_bytes([m[4], m[5]]), RTM_GETADDR);
assert_eq!(u16::from_le_bytes([m[6], m[7]]), NLM_F_REQUEST | NLM_F_DUMP);
assert_eq!(&m[16..], &[0u8; 8]);
}
#[test]
fn a_dump_reply_yields_the_dialable_addresses_and_skips_loopback_and_link_local() {
let mut buf = Vec::new();
buf.extend(newaddr(
AF_INET,
8,
RT_SCOPE_HOST,
1,
&[127, 0, 0, 1],
Some("lo"),
));
buf.extend(newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0")));
buf.extend(newaddr(
AF_INET6,
64,
RT_SCOPE_LINK,
2,
&[0xfe, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
None,
));
buf.extend(newaddr(
AF_INET6,
64,
0,
2,
&[0x20, 0x01, 0xd, 0xb8, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1],
None,
));
buf.extend(done());
let mut resolved = Vec::new();
let (addrs, finished) = parse_getaddr_reply(&buf, &mut |i| {
resolved.push(i);
Some(format!("if{i}-resolved"))
});
assert!(finished);
assert_eq!(
addrs.iter().map(|a| a.to_string()).collect::<Vec<_>>(),
["eth0 10.0.2.15/24", "if2-resolved 2001:db8::1/64"]
);
assert_eq!(
resolved,
[2],
"only the label-less address asked for a name"
);
}
#[test]
fn a_reply_without_done_says_so_and_a_truncated_one_stops_cleanly() {
let full = newaddr(AF_INET, 24, 0, 2, &[10, 0, 2, 15], Some("eth0"));
let (addrs, finished) = parse_getaddr_reply(&full, &mut |_| None);
assert_eq!(addrs.len(), 1);
assert!(!finished, "multipart without DONE: more datagrams follow");
let (addrs, _) = parse_getaddr_reply(&full[..full.len() - 3], &mut |_| None);
assert!(
addrs.is_empty(),
"a message whose declared length overruns the buffer is refused"
);
}
#[test]
fn the_live_readers_answer_on_this_box() {
let (ticks, cpus) = cpu_ticks();
assert!(ticks.is_some() && cpus >= 1);
let (total, avail) = memory().expect("meminfo");
assert!(total >= avail && total > 0);
assert!(uptime_secs().is_some());
let (dt, da) = disk(std::path::Path::new("/")).expect("statfs /");
assert!(dt >= da && dt > 0);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PciHotplug {
pub present: bool,
pub evidence: String,
}
pub const HOTPLUG_CONTROLLERS: [&str; 3] = ["acpiphp", "pciehp", "shpchp"];
pub fn decide_pci_hotplug(config: Option<&str>, has_module: &dyn Fn(&str) -> bool) -> PciHotplug {
if let Some(text) = config {
let y = |sym: &str| text.lines().any(|l| l.trim() == format!("{sym}=y"));
let controllers: Vec<&str> = [
"CONFIG_HOTPLUG_PCI_ACPI",
"CONFIG_HOTPLUG_PCI_PCIE",
"CONFIG_HOTPLUG_PCI_SHPC",
]
.into_iter()
.filter(|s| y(s))
.collect();
let present = y("CONFIG_HOTPLUG_PCI") && !controllers.is_empty();
return PciHotplug {
present,
evidence: if present {
format!("config.gz: CONFIG_HOTPLUG_PCI=y, {}", controllers.join(" "))
} else {
"config.gz: CONFIG_HOTPLUG_PCI or every controller is not set".to_owned()
},
};
}
let found: Vec<&str> = HOTPLUG_CONTROLLERS
.into_iter()
.filter(|m| has_module(m))
.collect();
PciHotplug {
present: !found.is_empty(),
evidence: if found.is_empty() {
"sys/module: no acpiphp, pciehp or shpchp (no /proc/config.gz)".to_owned()
} else {
format!("sys/module: {} (no /proc/config.gz)", found.join(" "))
},
}
}
pub fn pci_hotplug() -> PciHotplug {
use std::io::Read as _;
let config = std::fs::File::open("/proc/config.gz").ok().and_then(|f| {
let mut s = String::new();
flate2::read::GzDecoder::new(f)
.read_to_string(&mut s)
.ok()
.map(|_| s)
});
decide_pci_hotplug(config.as_deref(), &|m| {
std::path::Path::new("/sys/module").join(m).is_dir()
})
}
#[cfg(test)]
mod pci_hotplug_tests {
use super::*;
#[test]
fn config_gz_decides_when_present_and_needs_a_controller() {
let none = |_: &str| true; let hot = "CONFIG_PCI=y\nCONFIG_HOTPLUG_PCI=y\nCONFIG_HOTPLUG_PCI_ACPI=y\n";
let p = decide_pci_hotplug(Some(hot), &none);
assert!(
p.present && p.evidence.contains("HOTPLUG_PCI_ACPI"),
"{p:?}"
);
assert!(
!decide_pci_hotplug(Some("CONFIG_HOTPLUG_PCI=y\n"), &none).present,
"the core without a controller acknowledges nothing"
);
assert!(
!decide_pci_hotplug(
Some("# CONFIG_HOTPLUG_PCI is not set\nCONFIG_HOTPLUG_PCI_ACPI=y\n"),
&none
)
.present
);
}
#[test]
fn without_config_gz_the_builtin_controllers_decide() {
let p = decide_pci_hotplug(None, &|m| m == "pciehp" || m == "acpiphp");
assert!(
p.present && p.evidence == "sys/module: acpiphp pciehp (no /proc/config.gz)",
"{p:?}"
);
let p = decide_pci_hotplug(None, &|_| false);
assert!(!p.present && p.evidence.contains("no acpiphp"), "{p:?}");
}
}