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::route::RouteType,
};
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
#[derive(Debug, Default)]
#[must_use = "Diffs do nothing unless passed to `.apply()` or stringified via `Display`"]
pub struct ConfigDiff {
pub links_to_add: Vec<DeclaredLink>,
pub links_to_modify: Vec<(String, LinkChanges)>,
pub addresses_to_add: Vec<DeclaredAddress>,
pub routes_to_add: Vec<DeclaredRoute>,
pub qdiscs_to_add: Vec<DeclaredQdisc>,
pub qdiscs_to_replace: Vec<DeclaredQdisc>,
}
impl ConfigDiff {
pub async fn apply(
&self,
conn: &Connection<Route>,
opts: super::apply::ApplyOptions,
) -> Result<super::apply::ApplyResult> {
super::apply::apply_diff(self, conn, opts).await
}
pub fn is_empty(&self) -> bool {
self.links_to_add.is_empty()
&& self.links_to_modify.is_empty()
&& self.addresses_to_add.is_empty()
&& self.routes_to_add.is_empty()
&& self.qdiscs_to_add.is_empty()
&& self.qdiscs_to_replace.is_empty()
}
pub fn change_count(&self) -> usize {
self.links_to_add.len()
+ self.links_to_modify.len()
+ self.addresses_to_add.len()
+ self.routes_to_add.len()
+ self.qdiscs_to_add.len()
+ self.qdiscs_to_replace.len()
}
#[deprecated(
since = "0.19.0",
note = "use `Display` via `format!(\"{}\")` or `diff.to_string()` instead — Plan 188 §2.6"
)]
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, 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 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 qdisc in &self.qdiscs_to_add {
lines.push(format!(
"+ qdisc {} on {} ({:?})",
qdisc.qdisc_type.kind(),
qdisc.dev,
qdisc.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 {
#[allow(deprecated)]
f.write_str(&self.summary())
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
#[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(", ")
}
}
impl std::fmt::Display for LinkChanges {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.summary())
}
}
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, &ifindex_to_name, &mut diff);
topo_sort_links_to_add(&mut diff.links_to_add);
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>,
ifindex_to_name: &HashMap<u32, &str>,
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, ifindex_to_name);
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 topo_sort_links_to_add(links: &mut Vec<DeclaredLink>) {
if links.len() < 2 {
return;
}
let names_in_batch: HashSet<String> =
links.iter().map(|l| l.name.clone()).collect();
fn deps_of(link: &DeclaredLink, names_in_batch: &HashSet<String>) -> Vec<String> {
let mut deps = Vec::new();
match &link.link_type {
DeclaredLinkType::Vlan { parent, .. } => deps.push(parent.clone()),
DeclaredLinkType::Macvlan { parent, .. } => deps.push(parent.clone()),
DeclaredLinkType::Vxlan {
underlay_dev: Some(dev),
..
} => deps.push(dev.clone()),
_ => {}
}
if let Some(master) = &link.master {
deps.push(master.clone());
}
deps.retain(|d| names_in_batch.contains(d));
deps
}
let mut emitted: HashSet<String> = HashSet::new();
let mut out: Vec<DeclaredLink> = Vec::with_capacity(links.len());
let mut remaining: Vec<DeclaredLink> = std::mem::take(links);
while !remaining.is_empty() {
let before = remaining.len();
let mut next_remaining = Vec::with_capacity(remaining.len());
for link in remaining.into_iter() {
let deps = deps_of(&link, &names_in_batch);
let ready = deps.iter().all(|d| emitted.contains(d));
if ready {
emitted.insert(link.name.clone());
out.push(link);
} else {
next_remaining.push(link);
}
}
if next_remaining.len() == before {
out.extend(next_remaining);
break;
}
remaining = next_remaining;
}
*links = out;
}
fn compute_link_changes(
declared: &DeclaredLink,
existing: &LinkMessage,
ifindex_to_name: &HashMap<u32, &str>,
) -> LinkChanges {
let mut changes = LinkChanges::default();
let is_admin_up = existing.is_up();
match declared.state {
LinkState::Up => {
if !is_admin_up {
changes.set_up = true;
}
}
LinkState::Down => {
if is_admin_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);
}
let existing_master_name: Option<&str> = existing
.master()
.and_then(|idx| ifindex_to_name.get(&idx).copied());
match (declared.master.as_deref(), existing_master_name) {
(Some(want), Some(have)) if want == have => {
}
(Some(want), _) => {
changes.set_master = Some(want.to_string());
}
(None, Some(_)) => {
changes.unset_master = true;
}
(None, None) => {
}
}
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 name_to_ifindex: HashMap<&str, u32> = ifindex_to_name
.iter()
.map(|(idx, name)| (*name, *idx))
.collect();
let mut current_by_key: HashMap<(IpAddr, u8, u32), Vec<&RouteMessage>> = HashMap::new();
for r in current.iter().filter(|r| {
matches!(
r.route_type(),
RouteType::Unicast
| RouteType::Blackhole
| RouteType::Unreachable
| RouteType::Prohibit
)
}) {
let dst = r.destination.unwrap_or_else(|| {
if r.is_ipv4() {
IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED)
} else {
IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED)
}
});
current_by_key
.entry((dst, r.dst_len(), r.table_id()))
.or_default()
.push(r);
}
for declared in &config.routes {
let table = declared.table.unwrap_or(254);
let key = (declared.destination, declared.prefix_len, table);
let declared_oif = declared
.dev
.as_deref()
.and_then(|d| name_to_ifindex.get(d).copied());
let matches_existing = current_by_key.get(&key).is_some_and(|kernel_routes| {
kernel_routes.iter().any(|r| {
let gw_match = match (declared.gateway, r.gateway()) {
(None, None) => true,
(Some(a), Some(b)) => a == *b,
_ => false,
};
let dev_match = match (declared_oif, r.oif()) {
(None, None) => true,
(Some(a), Some(b)) => a == b,
(None, Some(_)) => true,
(Some(_), None) => false,
};
let metric_match = declared.metric.unwrap_or(0) == r.priority().unwrap_or(0);
gw_match && dev_match && metric_match
})
});
if !matches_existing {
diff.routes_to_add.push(declared.clone());
}
}
}
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::*;
use crate::netlink::config::types::MacvlanMode;
fn declared(name: &str, link_type: DeclaredLinkType) -> DeclaredLink {
DeclaredLink {
name: name.to_string(),
link_type,
state: LinkState::Unchanged,
mtu: None,
master: None,
address: None,
}
}
#[test]
fn topo_sort_no_op_when_empty_or_singleton() {
let mut links: Vec<DeclaredLink> = vec![];
topo_sort_links_to_add(&mut links);
assert!(links.is_empty());
let mut links = vec![declared("eth0", DeclaredLinkType::Dummy)];
topo_sort_links_to_add(&mut links);
assert_eq!(links.len(), 1);
assert_eq!(links[0].name, "eth0");
}
#[test]
fn topo_sort_independent_links_preserve_declared_order() {
let mut links = vec![
declared("eth1", DeclaredLinkType::Dummy),
declared("eth0", DeclaredLinkType::Dummy),
declared("br0", DeclaredLinkType::Bridge),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth1", "eth0", "br0"],
"independent links must keep declared order (stable sort)"
);
}
#[test]
fn topo_sort_promotes_parent_before_child_vlan() {
let mut links = vec![
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(),
vlan_id: 42, protocol: None,
},
),
declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth0", "eth0.42"],
"parent must precede child after topo-sort"
);
}
#[test]
fn topo_sort_keeps_correct_order_when_already_sorted() {
let mut links = vec![
declared("eth0", DeclaredLinkType::Dummy),
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(),
vlan_id: 42, protocol: None,
},
),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth0", "eth0.42"]
);
}
#[test]
fn topo_sort_handles_parent_not_in_batch() {
let mut links = vec![
declared("br0", DeclaredLinkType::Bridge),
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(), vlan_id: 42, protocol: None,
},
),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["br0", "eth0.42"],
"out-of-batch parent does NOT trigger reorder"
);
}
#[test]
fn topo_sort_handles_macvlan_parent_dep() {
let mut links = vec![
declared(
"macv0",
DeclaredLinkType::Macvlan {
parent: "eth0".into(),
mode: MacvlanMode::default(),
},
),
declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["eth0", "macv0"]
);
}
#[test]
fn topo_sort_chain_three_levels() {
let mut links = vec![
declared(
"eth0.42",
DeclaredLinkType::Vlan {
parent: "eth0".into(),
vlan_id: 42, protocol: None,
},
),
declared("br0", DeclaredLinkType::Bridge), declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
assert_eq!(
links.iter().map(|l| l.name.clone()).collect::<Vec<_>>(),
vec!["br0", "eth0", "eth0.42"]
);
}
#[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 apply_options_builders_compose() {
use super::super::apply::ApplyOptions;
let opts = ApplyOptions::default()
.with_dry_run(true)
.with_continue_on_error(true);
assert!(opts.dry_run);
assert!(opts.continue_on_error);
}
#[test]
fn apply_options_default_is_safe() {
use super::super::apply::ApplyOptions;
let opts = ApplyOptions::default();
assert!(!opts.dry_run);
assert!(!opts.continue_on_error);
}
#[test]
fn link_changes_display_matches_summary() {
let c = LinkChanges {
set_mtu: Some(9000),
set_up: true,
..LinkChanges::default()
};
assert_eq!(c.to_string(), c.summary());
assert!(c.to_string().contains("mtu=9000"));
assert!(c.to_string().contains("up"));
}
#[test]
fn link_changes_display_empty_when_no_changes() {
let c = LinkChanges::default();
assert_eq!(c.to_string(), "");
}
#[test]
fn default_v4_route_is_zero_zero() {
use super::super::types::RouteBuilder;
let r = RouteBuilder::default_v4();
let with_gw = r.via("192.0.2.1");
drop(with_gw);
}
#[test]
fn default_v6_route_is_unspecified_slash_zero() {
use super::super::types::RouteBuilder;
let r = RouteBuilder::default_v6();
let with_gw = r.via("2001:db8::1");
drop(with_gw);
}
#[test]
fn topo_sort_promotes_vxlan_underlay_before_vxlan() {
let mut links = vec![
declared(
"vxlan42",
DeclaredLinkType::Vxlan {
vni: 42,
remote: None,
local: None,
port: None,
underlay_dev: Some("eth0".into()),
},
),
declared("eth0", DeclaredLinkType::Dummy),
];
topo_sort_links_to_add(&mut links);
let positions: HashMap<String, usize> = links
.iter()
.enumerate()
.map(|(i, l)| (l.name.clone(), i))
.collect();
assert!(
positions["eth0"] < positions["vxlan42"],
"underlay must be created before VXLAN"
);
}
#[test]
fn topo_sort_promotes_master_before_slave() {
let mut dummy = declared("dummy0", DeclaredLinkType::Dummy);
dummy.master = Some("br0".into());
let mut links = vec![dummy, declared("br0", DeclaredLinkType::Bridge)];
topo_sort_links_to_add(&mut links);
let positions: HashMap<String, usize> = links
.iter()
.enumerate()
.map(|(i, l)| (l.name.clone(), i))
.collect();
assert!(
positions["br0"] < positions["dummy0"],
"master must be created before slave"
);
}
#[test]
fn display_matches_summary() {
let diff = ConfigDiff::default();
#[allow(deprecated)]
{
assert_eq!(format!("{diff}"), diff.summary());
let mut d = ConfigDiff::default();
d.links_to_modify
.push(("eth0".to_string(), LinkChanges::default()));
assert_eq!(format!("{d}"), d.summary());
}
}
}