use std::{
collections::{HashMap, HashSet},
net::IpAddr,
};
use std::time::Duration;
use super::types::{
DeclaredAddress, DeclaredLink, DeclaredLinkType, DeclaredQdisc, DeclaredQdiscType,
DeclaredRoute, LinkState, NetworkConfig, QdiscParent,
};
use crate::netlink::{
builder::MessageBuilder,
connection::Connection,
error::Result,
messages::{AddressMessage, LinkMessage, RouteMessage, TcMessage},
protocol::Route,
tc::{
ClsactConfig, FqCodelConfig, HtbQdiscConfig, IngressConfig, NetemConfig, PrioConfig,
QdiscConfig, SfqConfig, TbfConfig,
},
types::{link::OperState, route::RouteType},
};
#[derive(Debug, Default)]
pub struct ConfigDiff {
pub links_to_add: Vec<DeclaredLink>,
pub links_to_remove: Vec<String>,
pub links_to_modify: Vec<(String, LinkChanges)>,
pub addresses_to_add: Vec<DeclaredAddress>,
pub addresses_to_remove: Vec<(String, IpAddr, u8)>,
pub routes_to_add: Vec<DeclaredRoute>,
pub routes_to_remove: Vec<(IpAddr, u8, u32)>,
pub qdiscs_to_add: Vec<DeclaredQdisc>,
pub qdiscs_to_remove: Vec<(String, QdiscParent)>,
pub qdiscs_to_replace: Vec<DeclaredQdisc>,
}
impl ConfigDiff {
pub fn is_empty(&self) -> bool {
self.links_to_add.is_empty()
&& self.links_to_remove.is_empty()
&& self.links_to_modify.is_empty()
&& self.addresses_to_add.is_empty()
&& self.addresses_to_remove.is_empty()
&& self.routes_to_add.is_empty()
&& self.routes_to_remove.is_empty()
&& self.qdiscs_to_add.is_empty()
&& self.qdiscs_to_remove.is_empty()
&& self.qdiscs_to_replace.is_empty()
}
pub fn change_count(&self) -> usize {
self.links_to_add.len()
+ self.links_to_remove.len()
+ self.links_to_modify.len()
+ self.addresses_to_add.len()
+ self.addresses_to_remove.len()
+ self.routes_to_add.len()
+ self.routes_to_remove.len()
+ self.qdiscs_to_add.len()
+ self.qdiscs_to_remove.len()
+ self.qdiscs_to_replace.len()
}
pub fn summary(&self) -> String {
let mut lines = Vec::new();
for link in &self.links_to_add {
lines.push(format!(
"+ link {} ({})",
link.name,
link.link_type.kind().unwrap_or("physical")
));
}
for name in &self.links_to_remove {
lines.push(format!("- link {}", name));
}
for (name, changes) in &self.links_to_modify {
lines.push(format!("~ link {} ({})", name, changes.summary()));
}
for addr in &self.addresses_to_add {
lines.push(format!(
"+ address {}/{} on {}",
addr.address, addr.prefix_len, addr.dev
));
}
for (dev, addr, prefix) in &self.addresses_to_remove {
lines.push(format!("- address {}/{} on {}", addr, prefix, dev));
}
for route in &self.routes_to_add {
let via = route
.gateway
.map(|g| format!(" via {}", g))
.unwrap_or_default();
let dev = route
.dev
.as_ref()
.map(|d| format!(" dev {}", d))
.unwrap_or_default();
lines.push(format!(
"+ route {}/{}{}{}",
route.destination, route.prefix_len, via, dev
));
}
for (dst, prefix, table) in &self.routes_to_remove {
let table_str = if *table != 254 {
format!(" table {}", table)
} else {
String::new()
};
lines.push(format!("- route {}/{}{}", dst, prefix, table_str));
}
for qdisc in &self.qdiscs_to_add {
lines.push(format!(
"+ qdisc {} on {} ({:?})",
qdisc.qdisc_type.kind(),
qdisc.dev,
qdisc.parent
));
}
for (dev, parent) in &self.qdiscs_to_remove {
lines.push(format!("- qdisc on {} ({:?})", dev, parent));
}
for qdisc in &self.qdiscs_to_replace {
lines.push(format!(
"~ qdisc {} on {} ({:?})",
qdisc.qdisc_type.kind(),
qdisc.dev,
qdisc.parent
));
}
if lines.is_empty() {
"No changes needed".to_string()
} else {
lines.join("\n")
}
}
}
impl std::fmt::Display for ConfigDiff {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.summary())
}
}
#[derive(Debug, Default)]
pub struct LinkChanges {
pub set_up: bool,
pub set_down: bool,
pub set_mtu: Option<u32>,
pub set_master: Option<String>,
pub unset_master: bool,
}
impl LinkChanges {
pub fn is_empty(&self) -> bool {
!self.set_up
&& !self.set_down
&& self.set_mtu.is_none()
&& self.set_master.is_none()
&& !self.unset_master
}
pub fn summary(&self) -> String {
let mut parts: Vec<String> = Vec::new();
if self.set_up {
parts.push("up".to_string());
}
if self.set_down {
parts.push("down".to_string());
}
if let Some(mtu) = self.set_mtu {
parts.push(format!("mtu={}", mtu));
}
if let Some(master) = &self.set_master {
parts.push(format!("master={}", master));
}
if self.unset_master {
parts.push("nomaster".to_string());
}
parts.join(", ")
}
}
pub async fn compute_diff(config: &NetworkConfig, conn: &Connection<Route>) -> Result<ConfigDiff> {
let mut diff = ConfigDiff::default();
let current_links = conn.get_links().await?;
let current_addresses = conn.get_addresses().await?;
let current_routes = conn.get_routes().await?;
let current_qdiscs = conn.get_qdiscs().await?;
let link_by_name: HashMap<&str, &LinkMessage> = current_links
.iter()
.filter_map(|l| l.name.as_deref().map(|n| (n, l)))
.collect();
let ifindex_to_name: HashMap<u32, &str> = current_links
.iter()
.filter_map(|l| l.name.as_deref().map(|n| (l.ifindex(), n)))
.collect();
diff_links(config, &link_by_name, &mut diff);
diff_addresses(config, ¤t_addresses, &ifindex_to_name, &mut diff);
diff_routes(config, ¤t_routes, &ifindex_to_name, &mut diff);
diff_qdiscs(config, ¤t_qdiscs, &ifindex_to_name, &mut diff);
Ok(diff)
}
fn diff_links(
config: &NetworkConfig,
current: &HashMap<&str, &LinkMessage>,
diff: &mut ConfigDiff,
) {
let _desired_names: HashSet<&str> = config.links.iter().map(|l| l.name.as_str()).collect();
for declared in &config.links {
if let Some(existing) = current.get(declared.name.as_str()) {
let changes = compute_link_changes(declared, existing);
if !changes.is_empty() {
diff.links_to_modify.push((declared.name.clone(), changes));
}
} else {
if declared.link_type != DeclaredLinkType::Physical {
diff.links_to_add.push(declared.clone());
}
}
}
}
fn compute_link_changes(declared: &DeclaredLink, existing: &LinkMessage) -> LinkChanges {
let mut changes = LinkChanges::default();
match declared.state {
LinkState::Up => {
if existing.operstate != Some(OperState::Up) {
changes.set_up = true;
}
}
LinkState::Down => {
if existing.operstate == Some(OperState::Up) {
changes.set_down = true;
}
}
LinkState::Unchanged => {}
}
if let Some(desired_mtu) = declared.mtu
&& existing.mtu != Some(desired_mtu)
{
changes.set_mtu = Some(desired_mtu);
}
if declared.master.is_some() && existing.master.is_none() {
changes.set_master = declared.master.clone();
} else if declared.master.is_none() && existing.master.is_some() {
changes.unset_master = true;
}
changes
}
fn diff_addresses(
config: &NetworkConfig,
current: &[AddressMessage],
ifindex_to_name: &HashMap<u32, &str>,
diff: &mut ConfigDiff,
) {
let desired: HashSet<(&str, IpAddr, u8)> = config
.addresses
.iter()
.map(|a| (a.dev.as_str(), a.address, a.prefix_len))
.collect();
let current_set: HashSet<(&str, IpAddr, u8)> = current
.iter()
.filter_map(|a| {
let name = ifindex_to_name.get(&a.ifindex())?;
let addr = a.address?;
Some((*name, addr, a.prefix_len()))
})
.collect();
for declared in &config.addresses {
let key = (declared.dev.as_str(), declared.address, declared.prefix_len);
if !current_set.contains(&key) {
diff.addresses_to_add.push(declared.clone());
}
}
let _ = desired; }
fn diff_routes(
config: &NetworkConfig,
current: &[RouteMessage],
ifindex_to_name: &HashMap<u32, &str>,
diff: &mut ConfigDiff,
) {
let desired: HashSet<(IpAddr, u8, u32)> = config
.routes
.iter()
.map(|r| (r.destination, r.prefix_len, r.table.unwrap_or(254)))
.collect();
let current_set: HashSet<(IpAddr, u8, u32)> = current
.iter()
.filter(|r| {
matches!(
r.route_type(),
RouteType::Unicast
| RouteType::Blackhole
| RouteType::Unreachable
| RouteType::Prohibit
)
})
.map(|r| {
let dst = r.destination.unwrap_or_else(|| {
if r.is_ipv4() {
IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
} else {
IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)
}
});
(dst, r.dst_len(), r.table_id())
})
.collect();
for declared in &config.routes {
let table = declared.table.unwrap_or(254);
let key = (declared.destination, declared.prefix_len, table);
if !current_set.contains(&key) {
diff.routes_to_add.push(declared.clone());
}
}
let _ = desired;
let _ = ifindex_to_name;
}
fn diff_qdiscs(
config: &NetworkConfig,
current: &[TcMessage],
ifindex_to_name: &HashMap<u32, &str>,
diff: &mut ConfigDiff,
) {
let mut current_root_qdisc: HashMap<&str, &TcMessage> = HashMap::new();
let mut current_ingress_qdisc: HashMap<&str, &TcMessage> = HashMap::new();
for qdisc in current {
if let Some(name) = ifindex_to_name.get(&qdisc.ifindex()) {
if qdisc.is_root() {
current_root_qdisc.insert(*name, qdisc);
} else if qdisc.is_ingress() {
current_ingress_qdisc.insert(*name, qdisc);
}
}
}
for declared in &config.qdiscs {
let current_map = match declared.parent {
QdiscParent::Root => ¤t_root_qdisc,
QdiscParent::Ingress => ¤t_ingress_qdisc,
};
if let Some(existing) = current_map.get(declared.dev.as_str()) {
let existing_kind = existing.kind().unwrap_or("");
let desired_kind = declared.qdisc_type.kind();
if existing_kind != desired_kind {
diff.qdiscs_to_replace.push(declared.clone());
} else if !qdisc_params_match(&declared.qdisc_type, existing.raw_options()) {
diff.qdiscs_to_replace.push(declared.clone());
}
} else {
diff.qdiscs_to_add.push(declared.clone());
}
}
}
fn qdisc_params_match(declared: &DeclaredQdiscType, existing_opts: Option<&[u8]>) -> bool {
let declared_bytes = declared_options_bytes(declared);
let existing_bytes = existing_opts.unwrap_or(&[]);
declared_bytes.as_slice() == existing_bytes
}
fn declared_options_bytes(t: &DeclaredQdiscType) -> Vec<u8> {
let mut builder = MessageBuilder::new(0, 0);
let start = builder.len();
let write_result: Result<()> = match t {
DeclaredQdiscType::Netem {
delay_us,
jitter_us,
loss_percent,
limit,
} => {
let mut cfg = NetemConfig::new();
if let Some(d) = delay_us {
cfg = cfg.delay(Duration::from_micros(*d as u64));
}
if let Some(j) = jitter_us {
cfg = cfg.jitter(Duration::from_micros(*j as u64));
}
if let Some(l) = loss_percent {
cfg = cfg.loss(crate::util::Percent::new(*l));
}
if let Some(lim) = limit {
cfg = cfg.limit(*lim);
}
cfg.build().write_options(&mut builder)
}
DeclaredQdiscType::Htb { default_class } => {
HtbQdiscConfig::new()
.default_class(*default_class)
.write_options(&mut builder)
}
DeclaredQdiscType::FqCodel {
limit,
target_us,
interval_us,
} => {
let mut cfg = FqCodelConfig::new();
if let Some(lim) = limit {
cfg = cfg.limit(*lim);
}
if let Some(t) = target_us {
cfg = cfg.target(Duration::from_micros(*t as u64));
}
if let Some(i) = interval_us {
cfg = cfg.interval(Duration::from_micros(*i as u64));
}
cfg.write_options(&mut builder)
}
DeclaredQdiscType::Tbf {
rate_bps,
burst_bytes,
limit_bytes,
} => {
let mut cfg = TbfConfig::new()
.rate(crate::util::Rate::bytes_per_sec(*rate_bps))
.burst(crate::util::Bytes::new(*burst_bytes as u64));
if let Some(lim) = limit_bytes {
cfg = cfg.limit(crate::util::Bytes::new(*lim as u64));
}
cfg.write_options(&mut builder)
}
DeclaredQdiscType::Sfq { perturb_secs } => {
let mut cfg = SfqConfig::new();
if let Some(p) = perturb_secs {
cfg = cfg.perturb(*p as i32);
}
cfg.write_options(&mut builder)
}
DeclaredQdiscType::Prio { bands } => {
let mut cfg = PrioConfig::new();
if let Some(b) = bands {
cfg = cfg.bands(*b as i32);
}
cfg.write_options(&mut builder)
}
DeclaredQdiscType::Ingress => IngressConfig::new().write_options(&mut builder),
DeclaredQdiscType::Clsact => ClsactConfig::new().write_options(&mut builder),
};
let end = builder.len();
if write_result.is_err() {
return Vec::new();
}
builder.as_bytes()[start..end].to_vec()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn declared_options_bytes_differs_when_param_changes() {
let a = DeclaredQdiscType::Htb { default_class: 0x10 };
let b = DeclaredQdiscType::Htb { default_class: 0x20 };
assert_ne!(declared_options_bytes(&a), declared_options_bytes(&b));
}
#[test]
fn declared_options_bytes_stable_for_same_input() {
let cfg = DeclaredQdiscType::Netem {
delay_us: Some(100_000),
jitter_us: Some(10_000),
loss_percent: Some(0.5),
limit: Some(1000),
};
assert_eq!(declared_options_bytes(&cfg), declared_options_bytes(&cfg));
}
#[test]
fn declared_options_bytes_differs_across_netem_params() {
let a = DeclaredQdiscType::Netem {
delay_us: Some(100_000),
jitter_us: None,
loss_percent: None,
limit: None,
};
let b = DeclaredQdiscType::Netem {
delay_us: Some(200_000),
jitter_us: None,
loss_percent: None,
limit: None,
};
assert_ne!(declared_options_bytes(&a), declared_options_bytes(&b));
}
#[test]
fn qdisc_params_match_treats_empty_existing_as_mismatch_when_declared_nonempty() {
let cfg = DeclaredQdiscType::Htb { default_class: 0x10 };
assert!(!qdisc_params_match(&cfg, None));
assert!(!qdisc_params_match(&cfg, Some(&[])));
}
#[test]
fn qdisc_params_match_clsact_has_no_options() {
let cfg = DeclaredQdiscType::Clsact;
assert!(qdisc_params_match(&cfg, Some(&[])));
assert!(qdisc_params_match(&cfg, None));
}
#[test]
fn display_matches_summary() {
let diff = ConfigDiff::default();
assert_eq!(format!("{diff}"), diff.summary());
let mut d = ConfigDiff::default();
d.links_to_remove.push("eth0".to_string());
assert_eq!(format!("{d}"), d.summary());
}
}