diff --git a/Cargo.toml b/Cargo.toml index 698d3c6..ef527c0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,10 +20,11 @@ path = "src/ip/main.rs" clap = { version = "4.5.40", features = ["cargo"] } futures-util = "0.3.31" rtnetlink = { git = "https://github.com/rust-netlink/rtnetlink", branch = "use_git" } -serde = {version = "1.0", default-features = false, features = ["derive"]} +serde = { version = "1.0", default-features = false, features = ["derive"] } serde_json = "1.0.140" serde_yaml = "0.9.34" tokio = { version = "1.30", features = ["rt", "net", "time", "macros"] } [dev-dependencies] pretty_assertions = "1.4.1" +ctor = "0.5.0" diff --git a/src/ip/link/cli.rs b/src/ip/link/cli.rs index a24325b..8b604ec 100644 --- a/src/ip/link/cli.rs +++ b/src/ip/link/cli.rs @@ -53,9 +53,9 @@ impl LinkCommand { .unwrap_or_default() .map(String::as_str) .collect(); - handle_show(&opts).await + handle_show(&opts, matches.get_flag("DETAILS")).await } else { - handle_show(&[]).await + handle_show(&[], matches.get_flag("DETAILS")).await } } } diff --git a/src/ip/link/link_details.rs b/src/ip/link/link_details.rs new file mode 100644 index 0000000..19872cc --- /dev/null +++ b/src/ip/link/link_details.rs @@ -0,0 +1,197 @@ +use std::ffi::CStr; + +use rtnetlink::packet_core::DefaultNla; +use rtnetlink::{ + packet_core::Nla as _, + packet_route::link::{AfSpecInet6, AfSpecUnspec, LinkAttribute}, +}; +use serde::Serialize; + +use crate::link::link_info::CliLinkInfoKindNData; + +// Use constants until support is added to netlink-packet-route +const IFLA_PARENT_DEV_NAME: u16 = 56; +const IFLA_PARENT_DEV_BUS_NAME: u16 = 57; +const IFLA_GRO_MAX_SIZE: u16 = 58; +const IFLA_TSO_MAX_SIZE: u16 = 59; +const IFLA_TSO_MAX_SEGS: u16 = 60; +const IFLA_ALLMULTI: u16 = 61; + +fn get_addr_gen_mode(af_spec_unspec: &[AfSpecUnspec]) -> String { + af_spec_unspec + .iter() + .filter_map(|s| { + let AfSpecUnspec::Inet6(v) = s else { + return None; + }; + v.iter() + .filter_map(|i| { + if let AfSpecInet6::AddrGenMode(mode) = i { + Some(mode) + } else { + None + } + }) + .next() + }) + .next() + .map(|i| i.to_string()) + .unwrap_or_default() +} +fn default_nla_to_string(default_nla: &DefaultNla) -> String { + let val_len = default_nla.value_len(); + let mut val = vec![0u8; val_len]; + default_nla.emit_value(&mut val); + CStr::from_bytes_with_nul(&val) + .expect("String nla to be nul-terminated and not contain interior nuls") + .to_str() + .expect("To be valid UTF-8") + .to_string() +} + +#[derive(Serialize)] +pub(crate) struct CliLinkInfoDetails { + promiscuity: u32, + allmulti: u32, + min_mtu: u32, + max_mtu: u32, + #[serde(skip_serializing_if = "Option::is_none")] + linkinfo: Option, + #[serde(skip_serializing_if = "String::is_empty")] + inet6_addr_gen_mode: String, + num_tx_queues: u32, + num_rx_queues: u32, + gso_max_size: u32, + gso_max_segs: u32, + tso_max_size: u32, + tso_max_segs: u32, + gro_max_size: u32, + #[serde(skip_serializing_if = "String::is_empty")] + parentbus: String, + #[serde(skip_serializing_if = "String::is_empty")] + parentdev: String, +} + +impl CliLinkInfoDetails { + pub fn new(nl_attrs: &[LinkAttribute]) -> Self { + let mut linkinfo = None; + let mut promiscuity = 0; + let mut allmulti = 0; + let mut min_mtu = 0; + let mut max_mtu = 0; + let mut num_tx_queues = 0; + let mut num_rx_queues = 0; + let mut gso_max_size = 0; + let mut gso_max_segs = 0; + let mut tso_max_size = 0; + let mut tso_max_segs = 0; + let mut gro_max_size = 0; + let mut inet6_addr_gen_mode = String::new(); + let mut parentbus = String::new(); + let mut parentdev = String::new(); + + for nl_attr in nl_attrs { + match nl_attr { + LinkAttribute::Promiscuity(p) => promiscuity = *p, + LinkAttribute::MinMtu(m) => min_mtu = *m, + LinkAttribute::MaxMtu(m) => max_mtu = *m, + LinkAttribute::AfSpecUnspec(a) => { + inet6_addr_gen_mode = get_addr_gen_mode(a) + } + LinkAttribute::NumTxQueues(n) => num_tx_queues = *n, + LinkAttribute::NumRxQueues(n) => num_rx_queues = *n, + LinkAttribute::GsoMaxSize(g) => gso_max_size = *g, + LinkAttribute::GsoMaxSegs(g) => gso_max_segs = *g, + LinkAttribute::Other(default_nla) => match default_nla.kind() { + IFLA_PARENT_DEV_BUS_NAME => { + parentbus = default_nla_to_string(default_nla); + } + IFLA_PARENT_DEV_NAME => { + parentdev = default_nla_to_string(default_nla); + } + IFLA_GRO_MAX_SIZE => { + let mut val = [0u8; 4]; + default_nla.emit_value(&mut val); + gro_max_size = u32::from_ne_bytes(val); + } + IFLA_TSO_MAX_SIZE => { + let mut val = [0u8; 4]; + default_nla.emit_value(&mut val); + tso_max_size = u32::from_ne_bytes(val); + } + IFLA_TSO_MAX_SEGS => { + let mut val = [0u8; 4]; + default_nla.emit_value(&mut val); + tso_max_segs = u32::from_ne_bytes(val); + } + IFLA_ALLMULTI => { + let mut val = [0u8; 4]; + default_nla.emit_value(&mut val); + allmulti = u32::from_ne_bytes(val); + } + _ => { /* println!("Remains {:?}", default_nla); */ } + }, + LinkAttribute::LinkInfo(info) => { + linkinfo = CliLinkInfoKindNData::new(info); + } + _ => { + // println!("Remains {:?}", nl_attr); + } + } + } + + Self { + promiscuity, + allmulti, + min_mtu, + max_mtu, + linkinfo, + inet6_addr_gen_mode, + num_tx_queues, + num_rx_queues, + gso_max_size, + gso_max_segs, + tso_max_size, + tso_max_segs, + gro_max_size, + parentbus, + parentdev, + } + } +} + +impl std::fmt::Display for CliLinkInfoDetails { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + " promiscuity {} allmulti {} minmtu {} maxmtu {} ", + self.promiscuity, self.allmulti, self.min_mtu, self.max_mtu, + )?; + + if let Some(linkinfo) = &self.linkinfo { + write!(f, "{linkinfo}")?; + } + + write!( + f, + "addrgenmode {} numtxqueues {} numrxqueues {} gso_max_size {} gso_max_segs {} tso_max_size {} tso_max_segs {} gro_max_size {} ", + self.inet6_addr_gen_mode, + self.num_tx_queues, + self.num_rx_queues, + self.gso_max_size, + self.gso_max_segs, + self.tso_max_size, + self.tso_max_segs, + self.gro_max_size, + )?; + + if !self.parentbus.is_empty() { + write!(f, "parentbus {} ", self.parentbus)?; + } + if !self.parentdev.is_empty() { + write!(f, "parentdev {} ", self.parentdev)?; + } + + Ok(()) + } +} diff --git a/src/ip/link/link_info.rs b/src/ip/link/link_info.rs new file mode 100644 index 0000000..f6f2865 --- /dev/null +++ b/src/ip/link/link_info.rs @@ -0,0 +1,145 @@ +use rtnetlink::packet_route::link::{InfoData, LinkInfo}; +use serde::Serialize; + +const VLAN_FLAG_REORDER_HDR: u32 = 0x1; +const VLAN_FLAG_GVRP: u32 = 0x2; +const VLAN_FLAG_LOOSE_BINDING: u32 = 0x4; +const VLAN_FLAG_MVRP: u32 = 0x8; + +#[derive(Serialize)] +#[serde(untagged)] +enum CliLinkInfoData { + Vlan { + protocol: String, + id: u16, + flags: Vec, + }, +} + +impl CliLinkInfoData { + fn new(info_data: &InfoData) -> Self { + match info_data { + InfoData::Bridge(_info_bridge) => todo!(), + InfoData::Tun(_info_tun) => todo!(), + InfoData::Vlan(info_vlan) => { + use rtnetlink::packet_route::link::InfoVlan; + let mut id = 0; + let mut flags = Vec::new(); + let mut protocol = String::new(); + + for nla in info_vlan { + match nla { + InfoVlan::Id(v) => id = *v, + InfoVlan::Flags((flags_val, _)) => { + if flags_val & VLAN_FLAG_REORDER_HDR != 0 { + flags.push("REORDER_HDR".to_string()); + } + if flags_val & VLAN_FLAG_GVRP != 0 { + flags.push("GVRP".to_string()); + } + if flags_val & VLAN_FLAG_LOOSE_BINDING != 0 { + flags.push("LOOSE_BINDING".to_string()); + } + if flags_val & VLAN_FLAG_MVRP != 0 { + flags.push("MVRP".to_string()); + } + } + InfoVlan::Protocol(v) => { + protocol = v.to_string().to_uppercase(); + } + _ => (), + } + } + + Self::Vlan { + id, + flags, + protocol, + } + } + InfoData::Veth(_info_veth) => todo!(), + InfoData::Vxlan(_info_vxlan) => todo!(), + InfoData::Bond(_info_bond) => todo!(), + InfoData::IpVlan(_info_ip_vlan) => todo!(), + InfoData::IpVtap(_info_ip_vtap) => todo!(), + InfoData::MacVlan(_info_mac_vlan) => todo!(), + InfoData::MacVtap(_info_mac_vtap) => todo!(), + InfoData::GreTap(_info_gre_tap) => todo!(), + InfoData::GreTap6(_info_gre_tap6) => todo!(), + InfoData::SitTun(_info_sit_tun) => todo!(), + InfoData::GreTun(_info_gre_tun) => todo!(), + InfoData::GreTun6(_info_gre_tun6) => todo!(), + InfoData::Vti(_info_vti) => todo!(), + InfoData::Vrf(_info_vrf) => todo!(), + InfoData::Gtp(_info_gtp) => todo!(), + InfoData::Ipoib(_info_ipoib) => todo!(), + InfoData::Xfrm(_info_xfrm) => todo!(), + InfoData::MacSec(_info_mac_sec) => todo!(), + InfoData::Hsr(_info_hsr) => todo!(), + InfoData::Geneve(_info_geneve) => todo!(), + InfoData::Other(_items) => todo!(), + _ => todo!(), + } + } +} + +impl std::fmt::Display for CliLinkInfoData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CliLinkInfoData::Vlan { + id, + flags, + protocol, + } => { + write!(f, "protocol {} ", protocol)?; + write!(f, "id {} ", id)?; + if !flags.is_empty() { + write!(f, "<{}>", flags.as_slice().join(","))?; + } + } + } + + Ok(()) + } +} + +#[derive(Serialize)] +pub(crate) struct CliLinkInfoKindNData { + info_kind: String, + #[serde(skip_serializing_if = "Option::is_none")] + info_data: Option, +} + +impl std::fmt::Display for CliLinkInfoKindNData { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "\n ")?; + write!(f, "{} ", self.info_kind)?; + if let Some(data) = &self.info_data { + write!(f, "{data} ")?; + } + Ok(()) + } +} + +impl CliLinkInfoKindNData { + pub fn new(link_info: &[LinkInfo]) -> Option { + let mut info_kind = String::new(); + let mut info_data = Option::None; + for nla in link_info { + match nla { + LinkInfo::Kind(t) => { + info_kind = t.to_string(); + } + LinkInfo::Data(data) => { + info_data = Some(CliLinkInfoData::new(data)); + } + _ => (), + } + } + + Some(CliLinkInfoKindNData { + info_kind, + info_data, + }) + } +} diff --git a/src/ip/link/mod.rs b/src/ip/link/mod.rs index 0f1af48..08ac7e5 100644 --- a/src/ip/link/mod.rs +++ b/src/ip/link/mod.rs @@ -2,6 +2,8 @@ mod cli; mod flags; +mod link_details; +mod link_info; mod show; #[cfg(test)] diff --git a/src/ip/link/show.rs b/src/ip/link/show.rs index d2434dc..74b8fb4 100644 --- a/src/ip/link/show.rs +++ b/src/ip/link/show.rs @@ -1,7 +1,9 @@ // SPDX-License-Identifier: MIT use std::collections::HashMap; +use std::os::fd::AsRawFd; +use futures_util::stream::StreamExt; use futures_util::stream::TryStreamExt; use rtnetlink::packet_route::link::{LinkAttribute, LinkMessage}; use serde::Serialize; @@ -11,9 +13,15 @@ use iproute_rs::{ CanDisplay, CanOutput, CliColor, CliError, mac_to_string, write_with_color, }; +use crate::link::link_details::CliLinkInfoDetails; + #[derive(Serialize, Default)] pub(crate) struct CliLinkInfo { ifindex: u32, + #[serde(skip_serializing_if = "Option::is_none")] + link: Option, + #[serde(skip_serializing_if = "Option::is_none")] + link_index: Option, ifname: String, flags: Vec, mtu: u32, @@ -32,12 +40,32 @@ pub(crate) struct CliLinkInfo { address: String, #[serde(skip_serializing_if = "String::is_empty")] broadcast: String, + #[serde(skip)] + link_netns: String, + #[serde(skip_serializing_if = "Option::is_none")] + link_netnsid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + #[serde(flatten)] + details: Option, } impl std::fmt::Display for CliLinkInfo { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}: ", self.ifindex)?; - write_with_color!(f, CliColor::IfaceName, "{}: ", self.ifname)?; + let link = if self.link_index.is_some() || self.link.is_some() { + let display_name = if let Some(link_name) = &self.link { + link_name + } else if let Some(link_index) = self.link_index { + &format!("if{link_index}") + } else { + "NONE" + }; + format!("@{display_name}") + } else { + String::new() + }; + + write_with_color!(f, CliColor::IfaceName, "{}{link}: ", self.ifname)?; write!( f, "<{}> mtu {} qdisc {}", @@ -67,6 +95,16 @@ impl std::fmt::Display for CliLinkInfo { write!(f, " brd ")?; write_with_color!(f, CliColor::Mac, "{}", self.broadcast)?; } + + if !self.link_netns.is_empty() { + write!(f, " link-netns {}", self.link_netns)?; + } else if let Some(netns_id) = self.link_netnsid { + write!(f, " link-netnsid {netns_id}")?; + } + + if let Some(details) = &self.details { + write!(f, "{details}",)?; + } Ok(()) } } @@ -80,34 +118,35 @@ impl CanDisplay for CliLinkInfo { impl CanOutput for CliLinkInfo {} pub(crate) async fn handle_show( - _opts: &[&str], + opts: &[&str], + include_details: bool, ) -> Result, CliError> { let (connection, handle, _) = rtnetlink::new_connection()?; tokio::spawn(connection); - let link_get_handle = handle.link().get(); + let mut link_get_handle = handle.link().get(); - /* - if let Some(iface_name) = filter.iface_name.as_ref() { + if let Some(iface_name) = opts.first() { link_get_handle = link_get_handle.match_name(iface_name.to_string()); } - */ let mut links = link_get_handle.execute(); let mut ifaces: Vec = Vec::new(); while let Some(nl_msg) = links.try_next().await? { - ifaces.push(parse_nl_msg_to_iface(nl_msg)?); + ifaces.push(parse_nl_msg_to_iface(nl_msg, include_details).await?); } - resolve_controller_name(&mut ifaces); + resolve_controller_and_link_names(&mut ifaces); + resolve_netns_names(&mut ifaces).await?; Ok(ifaces) } -pub(crate) fn parse_nl_msg_to_iface( +pub(crate) async fn parse_nl_msg_to_iface( nl_msg: LinkMessage, + include_details: bool, ) -> Result { let mut ret = CliLinkInfo { ifindex: nl_msg.header.index, @@ -116,6 +155,9 @@ pub(crate) fn parse_nl_msg_to_iface( ..Default::default() }; + ret.details = + include_details.then(|| CliLinkInfoDetails::new(&nl_msg.attributes)); + for nl_attr in nl_msg.attributes { match nl_attr { LinkAttribute::IfName(name) => ret.ifname = name, @@ -139,14 +181,54 @@ pub(crate) fn parse_nl_msg_to_iface( } LinkAttribute::Mode(v) => ret.linkmode = v.to_string(), LinkAttribute::Controller(d) => ret.controller_ifindex = Some(d), + LinkAttribute::Link(i) => ret.link_index = Some(i), + LinkAttribute::LinkNetNsId(i) => ret.link_netnsid = Some(i), _ => { // println!("Remains {:?}", nl_attr); } } } + Ok(ret) } +/// Try to resolve a netns id to its name using rtnetlink. +/// If not found, returns the id as a string. +async fn get_netns_id_from_fd( + handle: &mut rtnetlink::Handle, + fd: u32, +) -> Option { + let mut nsid_msg = rtnetlink::packet_route::nsid::NsidMessage::default(); + nsid_msg + .attributes + .push(rtnetlink::packet_route::nsid::NsidAttribute::Fd(fd)); + let mut nsid_req = rtnetlink::packet_core::NetlinkMessage::new( + rtnetlink::packet_core::NetlinkHeader::default(), + rtnetlink::packet_core::NetlinkPayload::InnerMessage( + rtnetlink::packet_route::RouteNetlinkMessage::GetNsId(nsid_msg), + ), + ); + nsid_req.header.flags = rtnetlink::packet_core::NLM_F_REQUEST; + + let mut netns = handle.request(nsid_req.clone()).unwrap(); + + if let Some(msg) = netns.next().await { + let rtnetlink::packet_core::NetlinkPayload::InnerMessage( + rtnetlink::packet_route::RouteNetlinkMessage::NewNsId(payload), + ) = msg.payload + else { + return None; + }; + for attr in payload.attributes { + if let rtnetlink::packet_route::nsid::NsidAttribute::Id(id) = attr { + return Some(id); + } + } + } + + None +} + fn resolve_ip_link_group_name(id: u32) -> String { // TODO: Read `/usr/share/iproute2/group` and `/etc/iproute2/group` match id { @@ -155,7 +237,47 @@ fn resolve_ip_link_group_name(id: u32) -> String { } } -fn resolve_controller_name(links: &mut [CliLinkInfo]) { +async fn resolve_netns_names( + links: &mut [CliLinkInfo], +) -> Result<(), CliError> { + let (conn, mut handle, _) = rtnetlink::new_connection().unwrap(); + tokio::spawn(conn); + + // Read netns names from /run/netns + let netnses = std::fs::read_dir("/run/netns"); + if let Err(e) = &netnses + && e.kind() == std::io::ErrorKind::NotFound + { + // No /run/netns, nothing to resolve + return Ok(()); + } + let netnses = netnses?; + + let mut id_to_name: HashMap = HashMap::new(); + for netns in netnses { + let netns = netns?; + let name = netns.file_name().into_string().unwrap_or_default(); + let file = std::fs::File::open(netns.path())?; + + if let Some(id) = + get_netns_id_from_fd(&mut handle, file.as_raw_fd() as u32).await + { + id_to_name.insert(id, name); + } + } + + for link in links.iter_mut() { + if let Some(link_netns_id) = link.link_netnsid + && let Some(name) = id_to_name.get(&link_netns_id) + { + link.link_netns = name.to_string(); + } + } + + Ok(()) +} + +fn resolve_controller_and_link_names(links: &mut [CliLinkInfo]) { let index_2_name: HashMap = links .iter() .map(|l| (l.ifindex, l.ifname.to_string())) @@ -167,5 +289,20 @@ fn resolve_controller_name(links: &mut [CliLinkInfo]) { { link.controller = Some(name.to_string()); } + if let Some(link_ifindex) = link.link_index { + if link_ifindex == 0 { + continue; + } + + // Only set link name if the link is from the current netns + if let Some(name) = index_2_name.get(&link_ifindex) + && link.link_netnsid.is_none() + { + link.link = Some(name.to_string()); + // Clear link_index if we have a name + // We want to serialize one or the other + link.link_index = None; + } + } } } diff --git a/src/ip/link/tests/link.rs b/src/ip/link/tests/link.rs index 142244f..3657467 100644 --- a/src/ip/link/tests/link.rs +++ b/src/ip/link/tests/link.rs @@ -2,6 +2,22 @@ use crate::tests::{exec_cmd, get_ip_cli_path}; +#[cfg(test)] +#[ctor::ctor] +fn setup() { + println!("setup for interfaces for tests"); + + // Add vlan over dummy interface + exec_cmd(&["ip", "link", "add", "link", "dummy0", "type", "dummy"]); + exec_cmd(&[ + "ip", "link", "add", "link", "dummy0", "name", "dummy0.1", "type", + "vlan", "id", "1", + ]); + + exec_cmd(&["ip", "link", "set", "dummy0", "up"]); + exec_cmd(&["ip", "link", "set", "dummy0.1", "up"]); +} + #[test] fn test_link_show() { let cli_path = get_ip_cli_path(); @@ -13,6 +29,17 @@ fn test_link_show() { pretty_assertions::assert_eq!(expected_output, our_output); } +#[test] +fn test_link_detailed_show() { + let cli_path = get_ip_cli_path(); + + let expected_output = exec_cmd(&["ip", "-d", "link", "show"]); + + let our_output = exec_cmd(&[cli_path.as_str(), "-d", "link", "show"]); + + pretty_assertions::assert_eq!(expected_output, our_output); +} + #[test] fn test_link_show_json() { let cli_path = get_ip_cli_path(); @@ -23,3 +50,14 @@ fn test_link_show_json() { pretty_assertions::assert_eq!(expected_output, our_output); } + +#[test] +fn test_link_detailed_show_json() { + let cli_path = get_ip_cli_path(); + + let expected_output = exec_cmd(&["ip", "-d", "-j", "link", "show"]); + + let our_output = exec_cmd(&[cli_path.as_str(), "-d", "-j", "link", "show"]); + + pretty_assertions::assert_eq!(expected_output, our_output); +} diff --git a/src/ip/main.rs b/src/ip/main.rs index bf38625..cc35755 100644 --- a/src/ip/main.rs +++ b/src/ip/main.rs @@ -47,6 +47,13 @@ async fn main() -> Result<(), CliError> { .action(clap::ArgAction::SetTrue) .global(true), ) + .arg( + clap::Arg::new("DETAILS") + .short('d') + .help("Interface details") + .action(clap::ArgAction::SetTrue) + .global(true), + ) .subcommand_required(true) .subcommand(LinkCommand::gen_command()); diff --git a/src/mac.rs b/src/mac.rs index 5a3119f..3cf26a2 100644 --- a/src/mac.rs +++ b/src/mac.rs @@ -3,11 +3,18 @@ use std::fmt::Write; pub fn mac_to_string(data: &[u8]) -> String { + let as_ip = data.len() == 4; + let sep = if as_ip { '.' } else { ':' }; let mut rt = String::new(); for (i, m) in data.iter().enumerate().take(data.len()) { - write!(rt, "{m:02x}").ok(); + if as_ip { + write!(rt, "{m}").ok(); + } else { + write!(rt, "{m:02x}").ok(); + } + if i != data.len() - 1 { - rt.push(':'); + rt.push(sep); } } rt