use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
use std::sync::Arc;
use sha2::{Digest, Sha256};
use crate::payload::{Body, Entry, Payload, TextData};
use super::{FetchContext, Fetcher, RealtimeFetcher};
mod gateway;
mod interfaces;
mod ip;
mod listening_ports;
mod mac;
mod proxy;
mod speedtest;
mod vpn;
pub use gateway::NetGateway;
pub use interfaces::NetInterfaces;
pub use ip::NetIp;
pub use listening_ports::NetListeningPorts;
pub use mac::NetMac;
pub use proxy::NetProxy;
pub use speedtest::NetSpeedtest;
pub use vpn::NetVpn;
pub fn cached_fetchers() -> Vec<Arc<dyn Fetcher>> {
vec![
Arc::new(NetInterfaces),
Arc::new(NetIp),
Arc::new(NetMac),
Arc::new(NetGateway),
Arc::new(NetSpeedtest),
Arc::new(NetVpn),
Arc::new(NetListeningPorts),
]
}
pub fn realtime_fetchers() -> Vec<Arc<dyn RealtimeFetcher>> {
vec![Arc::new(NetProxy)]
}
#[derive(Debug, Clone)]
pub(crate) struct NetIface {
pub name: String,
pub kind: String,
pub mac: Option<String>,
pub ipv4: Vec<Ipv4Addr>,
pub ipv6: Vec<Ipv6Addr>,
pub up: bool,
pub loopback: bool,
pub is_default: bool,
pub mtu: Option<u32>,
pub rx_bytes: u64,
pub tx_bytes: u64,
}
#[derive(Debug, Clone, Default)]
pub(crate) struct GatewayInfo {
pub ip: Option<IpAddr>,
pub interface: Option<String>,
}
pub(crate) fn snapshot() -> Vec<NetIface> {
netdev::get_interfaces()
.into_iter()
.map(|mut i| {
let _ = i.update_stats();
NetIface {
loopback: i.is_loopback(),
up: i.is_up(),
is_default: i.default,
kind: i.if_type.name(),
mac: i.mac_addr.map(|m| m.to_string()),
ipv4: i.ipv4.iter().map(|n| n.addr()).collect(),
ipv6: i.ipv6.iter().map(|n| n.addr()).collect(),
mtu: i.mtu,
rx_bytes: i.stats.as_ref().map(|s| s.rx_bytes).unwrap_or(0),
tx_bytes: i.stats.as_ref().map(|s| s.tx_bytes).unwrap_or(0),
name: i.name,
}
})
.collect()
}
pub(crate) fn default_gateway() -> GatewayInfo {
for iface in netdev::get_interfaces() {
if iface.default {
return GatewayInfo {
ip: iface.gateway.as_ref().and_then(gateway_ip),
interface: Some(iface.name),
};
}
}
GatewayInfo {
ip: netdev::get_default_gateway()
.ok()
.as_ref()
.and_then(gateway_ip),
interface: None,
}
}
fn gateway_ip(dev: &netdev::NetworkDevice) -> Option<IpAddr> {
dev.ipv4
.first()
.copied()
.map(IpAddr::V4)
.or_else(|| dev.ipv6.first().copied().map(IpAddr::V6))
}
pub(crate) fn primary_interface(ifaces: &[NetIface]) -> Option<&NetIface> {
ifaces
.iter()
.find(|i| i.is_default)
.or_else(|| ifaces.iter().find(|i| i.up && !i.loopback))
}
pub(crate) fn payload(body: Body) -> Payload {
Payload {
icon: None,
status: None,
format: None,
body,
}
}
pub(crate) fn entry(key: &str, value: &str) -> Entry {
Entry {
key: key.into(),
value: Some(value.into()),
status: None,
}
}
pub(crate) fn parse_options<T: serde::de::DeserializeOwned + Default>(
raw: Option<&toml::Value>,
) -> Result<T, String> {
match raw {
None => Ok(T::default()),
Some(value) => value
.clone()
.try_into::<T>()
.map_err(|e| format!("invalid options: {e}")),
}
}
pub(crate) fn options_placeholder(msg: &str) -> Payload {
payload(Body::Text(TextData {
value: format!("⚠ {msg}"),
}))
}
pub(crate) fn cache_key(name: &str, ctx: &FetchContext) -> String {
let shape = ctx.shape.map(|s| s.as_str()).unwrap_or("");
let opts = ctx
.options
.as_ref()
.and_then(|v| toml::to_string(v).ok())
.unwrap_or_default();
let raw = format!("{name}|{shape}|{opts}");
let digest = Sha256::digest(raw.as_bytes());
let hex: String = digest.iter().take(8).map(|b| format!("{b:02x}")).collect();
format!("{name}-{hex}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fetcher::Safety;
use crate::render::Shape;
pub(crate) fn iface(name: &str, ipv4: &[&str], up: bool) -> NetIface {
NetIface {
name: name.into(),
kind: "Ethernet".into(),
mac: Some("aa:bb:cc:dd:ee:ff".into()),
ipv4: ipv4.iter().map(|s| s.parse().unwrap()).collect(),
ipv6: vec![],
up,
loopback: name == "lo",
is_default: false,
mtu: Some(1500),
rx_bytes: 0,
tx_bytes: 0,
}
}
#[test]
fn families_register_expected_names() {
let cached: Vec<_> = cached_fetchers()
.into_iter()
.map(|f| f.name().to_string())
.collect();
let realtime: Vec<_> = realtime_fetchers()
.into_iter()
.map(|f| f.name().to_string())
.collect();
assert_eq!(
cached,
vec![
"net_interfaces",
"net_ip",
"net_mac",
"net_gateway",
"net_speedtest",
"net_vpn",
"net_listening_ports",
]
);
assert_eq!(realtime, vec!["net_proxy"]);
cached_fetchers()
.iter()
.for_each(|f| assert_eq!(f.safety(), Safety::Safe));
realtime_fetchers()
.iter()
.for_each(|f| assert_eq!(f.safety(), Safety::Safe));
}
#[test]
fn primary_interface_prefers_default_then_first_up_non_loopback() {
let mut def = iface("eth0", &["10.0.0.2"], true);
def.is_default = true;
let ifaces = vec![iface("lo", &["127.0.0.1"], true), def];
assert_eq!(primary_interface(&ifaces).unwrap().name, "eth0");
let no_default = vec![iface("lo", &["127.0.0.1"], true), iface("wlan0", &[], true)];
assert_eq!(primary_interface(&no_default).unwrap().name, "wlan0");
let all_down = vec![iface("lo", &["127.0.0.1"], true)];
assert!(primary_interface(&all_down).is_none());
}
#[test]
fn parse_options_defaults_on_none_and_rejects_unknown_keys() {
#[derive(Debug, Default, serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Opt {
#[serde(default)]
kind: Option<String>,
}
assert!(parse_options::<Opt>(None).unwrap().kind.is_none());
let ok: toml::Value = toml::from_str("kind = \"v4\"").unwrap();
assert_eq!(
parse_options::<Opt>(Some(&ok)).unwrap().kind.as_deref(),
Some("v4")
);
let bad: toml::Value = toml::from_str("bogus = true").unwrap();
assert!(parse_options::<Opt>(Some(&bad)).is_err());
}
#[test]
fn cache_key_is_name_prefixed_and_options_sensitive() {
let base = FetchContext {
shape: Some(Shape::Text),
..Default::default()
};
let with_opts = FetchContext {
options: Some(toml::from_str("kind = \"v6\"").unwrap()),
..base.clone()
};
assert!(cache_key("net_ip", &base).starts_with("net_ip-"));
assert_ne!(cache_key("net_ip", &base), cache_key("net_ip", &with_opts));
}
#[test]
fn options_placeholder_wraps_message_in_warning_text() {
let Body::Text(t) = options_placeholder("bad config").body else {
panic!("expected text body");
};
assert_eq!(t.value, "⚠ bad config");
}
#[test]
fn default_gateway_returns_a_consistent_snapshot() {
let first = default_gateway();
let again = default_gateway();
assert_eq!(first.ip.is_some(), again.ip.is_some());
assert_eq!(first.interface.is_some(), again.interface.is_some());
}
#[test]
fn gateway_ip_prefers_ipv4_then_falls_back_to_ipv6() {
let mut dev = netdev::NetworkDevice::new();
dev.ipv4 = vec![Ipv4Addr::new(10, 0, 0, 1)];
assert_eq!(
gateway_ip(&dev),
Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))
);
let mut v6_only = netdev::NetworkDevice::new();
v6_only.ipv6 = vec!["fe80::1".parse().unwrap()];
assert_eq!(
gateway_ip(&v6_only),
Some(IpAddr::V6("fe80::1".parse().unwrap())),
);
let empty = netdev::NetworkDevice::new();
assert!(gateway_ip(&empty).is_none());
}
}