use std::net::IpAddr;
use std::time::Duration;
pub use crate::netlink::link::{
AdSelect as BondAdSelect, LacpRate as BondLacpRate, NetkitMode, NetkitPolicy, NetkitScrub,
VlanProtocol,
};
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case", default))]
#[derive(Debug, Clone, Default)]
pub struct NetworkConfig {
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty"))]
pub(crate) links: Vec<DeclaredLink>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty"))]
pub(crate) addresses: Vec<DeclaredAddress>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty"))]
pub(crate) routes: Vec<DeclaredRoute>,
#[cfg_attr(feature = "serde", serde(skip_serializing_if = "Vec::is_empty"))]
pub(crate) qdiscs: Vec<DeclaredQdisc>,
}
impl NetworkConfig {
pub fn new() -> Self {
Self::default()
}
#[cfg(feature = "serde")]
pub fn from_json_str(s: &str) -> crate::netlink::Result<Self> {
serde_json::from_str(s)
.map_err(|e| crate::netlink::Error::InvalidMessage(format!("config JSON parse: {e}")))
}
#[cfg(feature = "serde")]
pub fn to_json_string(&self) -> crate::netlink::Result<String> {
serde_json::to_string(self).map_err(|e| {
crate::netlink::Error::InvalidMessage(format!("config JSON serialize: {e}"))
})
}
#[cfg(feature = "serde")]
pub fn to_json_string_pretty(&self) -> crate::netlink::Result<String> {
serde_json::to_string_pretty(self).map_err(|e| {
crate::netlink::Error::InvalidMessage(format!("config JSON serialize: {e}"))
})
}
#[cfg(feature = "schemars")]
pub fn json_schema() -> String {
let schema = Self::json_schema_value();
serde_json::to_string_pretty(&schema).expect("schema serialization is infallible")
}
#[cfg(feature = "schemars")]
pub fn json_schema_value() -> schemars::Schema {
schemars::generate::SchemaSettings::draft07()
.into_generator()
.into_root_schema_for::<NetworkConfig>()
}
pub fn link(mut self, name: &str, f: impl FnOnce(LinkBuilder) -> LinkBuilder) -> Self {
let builder = f(LinkBuilder::new(name));
self.links.push(builder.build());
self
}
pub fn address(mut self, dev: &str, addr: &str) -> Result<Self, AddressParseError> {
let declared = DeclaredAddress::parse(dev, addr)?;
self.addresses.push(declared);
Ok(self)
}
pub fn route(
mut self,
dst: &str,
f: impl FnOnce(RouteBuilder) -> RouteBuilder,
) -> Result<Self, RouteParseError> {
let builder = f(RouteBuilder::new(dst)?);
self.routes.push(builder.build());
Ok(self)
}
pub fn qdisc(mut self, dev: &str, f: impl FnOnce(QdiscBuilder) -> QdiscBuilder) -> Self {
let builder = f(QdiscBuilder::new(dev));
self.qdiscs.push(builder.build());
self
}
pub fn links(&self) -> &[DeclaredLink] {
&self.links
}
pub fn addresses(&self) -> &[DeclaredAddress] {
&self.addresses
}
pub fn routes(&self) -> &[DeclaredRoute] {
&self.routes
}
pub fn qdiscs(&self) -> &[DeclaredQdisc] {
&self.qdiscs
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
#[derive(Debug, Clone)]
pub struct DeclaredLink {
pub(crate) name: String,
pub(crate) link_type: DeclaredLinkType,
#[cfg_attr(feature = "serde", serde(default))]
pub(crate) state: LinkState,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
pub(crate) mtu: Option<u32>,
#[cfg_attr(feature = "serde", serde(default, skip_serializing_if = "Option::is_none"))]
pub(crate) master: Option<String>,
#[cfg_attr(
feature = "serde",
serde(default, with = "mac_serde", skip_serializing_if = "Option::is_none")
)]
#[cfg_attr(feature = "schemars", schemars(with = "Option<String>"))]
pub(crate) address: Option<[u8; 6]>,
}
impl DeclaredLink {
pub fn name(&self) -> &str {
&self.name
}
pub fn link_type(&self) -> &DeclaredLinkType {
&self.link_type
}
pub fn state(&self) -> LinkState {
self.state
}
pub fn mtu(&self) -> Option<u32> {
self.mtu
}
pub fn master(&self) -> Option<&str> {
self.master.as_deref()
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum DeclaredLinkType {
Dummy,
Veth { peer: String },
Bridge,
Vlan {
parent: String,
vlan_id: u16,
protocol: Option<VlanProtocol>,
},
Vxlan {
vni: u32,
remote: Option<IpAddr>,
local: Option<IpAddr>,
port: Option<u16>,
underlay_dev: Option<String>,
},
Macvlan { parent: String, mode: MacvlanMode },
Bond {
mode: BondMode,
miimon: Option<u32>,
xmit_hash_policy: Option<u8>,
min_links: Option<u32>,
ad_select: Option<BondAdSelect>,
lacp_rate: Option<BondLacpRate>,
downdelay: Option<u32>,
updelay: Option<u32>,
resend_igmp: Option<u32>,
},
Ifb,
Vrf { table: u32 },
Ovpn,
Netkit {
peer: String,
mode: Option<NetkitMode>,
primary_policy: Option<NetkitPolicy>,
peer_policy: Option<NetkitPolicy>,
scrub: Option<NetkitScrub>,
peer_scrub: Option<NetkitScrub>,
},
Physical,
}
impl DeclaredLinkType {
pub fn kind(&self) -> Option<&str> {
match self {
Self::Dummy => Some("dummy"),
Self::Veth { .. } => Some("veth"),
Self::Bridge => Some("bridge"),
Self::Vlan { .. } => Some("vlan"),
Self::Vxlan { .. } => Some("vxlan"),
Self::Macvlan { .. } => Some("macvlan"),
Self::Bond { .. } => Some("bond"),
Self::Ifb => Some("ifb"),
Self::Vrf { .. } => Some("vrf"),
Self::Netkit { .. } => Some("netkit"),
Self::Ovpn => Some("ovpn"),
Self::Physical => None,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum LinkState {
Up,
#[default]
Down,
Unchanged,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum MacvlanMode {
Private,
Vepa,
#[default]
Bridge,
Passthru,
Source,
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum BondMode {
#[default]
BalanceRr,
ActiveBackup,
BalanceXor,
Broadcast,
Ieee802_3ad,
BalanceTlb,
BalanceAlb,
}
#[derive(Debug)]
#[must_use = "builders do nothing unless used"]
pub struct LinkBuilder {
name: String,
link_type: DeclaredLinkType,
state: LinkState,
mtu: Option<u32>,
master: Option<String>,
address: Option<[u8; 6]>,
}
impl LinkBuilder {
fn new(name: &str) -> Self {
Self {
name: name.to_string(),
link_type: DeclaredLinkType::Physical,
state: LinkState::Unchanged,
mtu: None,
master: None,
address: None,
}
}
pub fn dummy(mut self) -> Self {
self.link_type = DeclaredLinkType::Dummy;
self
}
pub fn veth(mut self, peer: &str) -> Self {
self.link_type = DeclaredLinkType::Veth {
peer: peer.to_string(),
};
self
}
pub fn bridge(mut self) -> Self {
self.link_type = DeclaredLinkType::Bridge;
self
}
pub fn vlan(mut self, parent: &str, vlan_id: u16) -> Self {
self.link_type = DeclaredLinkType::Vlan {
parent: parent.to_string(),
vlan_id,
protocol: None,
};
self
}
pub fn vlan_protocol(mut self, p: VlanProtocol) -> Self {
if let DeclaredLinkType::Vlan { protocol, .. } = &mut self.link_type {
*protocol = Some(p);
}
self
}
pub fn vxlan(mut self, vni: u32) -> Self {
self.link_type = DeclaredLinkType::Vxlan {
vni,
remote: None,
local: None,
port: None,
underlay_dev: None,
};
self
}
pub fn vxlan_remote(mut self, remote_addr: IpAddr) -> Self {
if let DeclaredLinkType::Vxlan { remote, .. } = &mut self.link_type {
*remote = Some(remote_addr);
}
self
}
pub fn vxlan_local(mut self, local_addr: IpAddr) -> Self {
if let DeclaredLinkType::Vxlan { local, .. } = &mut self.link_type {
*local = Some(local_addr);
}
self
}
pub fn vxlan_port(mut self, udp_port: u16) -> Self {
if let DeclaredLinkType::Vxlan { port, .. } = &mut self.link_type {
*port = Some(udp_port);
}
self
}
pub fn vxlan_underlay_dev(mut self, dev: impl Into<String>) -> Self {
if let DeclaredLinkType::Vxlan {
underlay_dev, ..
} = &mut self.link_type
{
*underlay_dev = Some(dev.into());
}
self
}
pub fn macvlan(mut self, parent: &str) -> Self {
self.link_type = DeclaredLinkType::Macvlan {
parent: parent.to_string(),
mode: MacvlanMode::default(),
};
self
}
pub fn macvlan_mode(mut self, mode: MacvlanMode) -> Self {
if let DeclaredLinkType::Macvlan { parent, .. } = &self.link_type {
self.link_type = DeclaredLinkType::Macvlan {
parent: parent.clone(),
mode,
};
}
self
}
pub fn bond(mut self) -> Self {
self.link_type = DeclaredLinkType::Bond {
mode: BondMode::default(),
miimon: None,
xmit_hash_policy: None,
min_links: None,
ad_select: None,
lacp_rate: None,
downdelay: None,
updelay: None,
resend_igmp: None,
};
self
}
pub fn bond_mode(mut self, mode: BondMode) -> Self {
if let DeclaredLinkType::Bond {
mode: ref mut m, ..
} = self.link_type
{
*m = mode;
}
self
}
pub fn miimon(mut self, ms: u32) -> Self {
if let DeclaredLinkType::Bond { miimon, .. } = &mut self.link_type {
*miimon = Some(ms);
}
self
}
pub fn xmit_hash_policy(mut self, policy: u8) -> Self {
if let DeclaredLinkType::Bond {
xmit_hash_policy, ..
} = &mut self.link_type
{
*xmit_hash_policy = Some(policy);
}
self
}
pub fn min_links(mut self, count: u32) -> Self {
if let DeclaredLinkType::Bond { min_links, .. } = &mut self.link_type {
*min_links = Some(count);
}
self
}
pub fn bond_ad_select(mut self, sel: BondAdSelect) -> Self {
if let DeclaredLinkType::Bond { ad_select, .. } = &mut self.link_type {
*ad_select = Some(sel);
}
self
}
pub fn bond_lacp_rate(mut self, rate: BondLacpRate) -> Self {
if let DeclaredLinkType::Bond { lacp_rate, .. } = &mut self.link_type {
*lacp_rate = Some(rate);
}
self
}
pub fn bond_downdelay(mut self, ms: u32) -> Self {
if let DeclaredLinkType::Bond { downdelay, .. } = &mut self.link_type {
*downdelay = Some(ms);
}
self
}
pub fn bond_updelay(mut self, ms: u32) -> Self {
if let DeclaredLinkType::Bond { updelay, .. } = &mut self.link_type {
*updelay = Some(ms);
}
self
}
pub fn bond_resend_igmp(mut self, count: u32) -> Self {
if let DeclaredLinkType::Bond { resend_igmp, .. } = &mut self.link_type {
*resend_igmp = Some(count);
}
self
}
pub fn ifb(mut self) -> Self {
self.link_type = DeclaredLinkType::Ifb;
self
}
pub fn ovpn(mut self) -> Self {
self.link_type = DeclaredLinkType::Ovpn;
self
}
pub fn netkit(mut self, peer: impl Into<String>) -> Self {
self.link_type = DeclaredLinkType::Netkit {
peer: peer.into(),
mode: None,
primary_policy: None,
peer_policy: None,
scrub: None,
peer_scrub: None,
};
self
}
pub fn netkit_mode(mut self, m: NetkitMode) -> Self {
if let DeclaredLinkType::Netkit { mode, .. } = &mut self.link_type {
*mode = Some(m);
}
self
}
pub fn netkit_primary_policy(mut self, p: NetkitPolicy) -> Self {
if let DeclaredLinkType::Netkit { primary_policy, .. } = &mut self.link_type {
*primary_policy = Some(p);
}
self
}
pub fn netkit_peer_policy(mut self, p: NetkitPolicy) -> Self {
if let DeclaredLinkType::Netkit { peer_policy, .. } = &mut self.link_type {
*peer_policy = Some(p);
}
self
}
pub fn netkit_scrub(mut self, s: NetkitScrub) -> Self {
if let DeclaredLinkType::Netkit { scrub, .. } = &mut self.link_type {
*scrub = Some(s);
}
self
}
pub fn netkit_peer_scrub(mut self, s: NetkitScrub) -> Self {
if let DeclaredLinkType::Netkit { peer_scrub, .. } = &mut self.link_type {
*peer_scrub = Some(s);
}
self
}
pub fn vrf(mut self, table: u32) -> Self {
self.link_type = DeclaredLinkType::Vrf { table };
self
}
pub fn up(mut self) -> Self {
self.state = LinkState::Up;
self
}
pub fn down(mut self) -> Self {
self.state = LinkState::Down;
self
}
pub fn mtu(mut self, mtu: u32) -> Self {
self.mtu = Some(mtu);
self
}
pub fn master(mut self, master: &str) -> Self {
self.master = Some(master.to_string());
self
}
pub fn address(mut self, addr: [u8; 6]) -> Self {
self.address = Some(addr);
self
}
fn build(self) -> DeclaredLink {
DeclaredLink {
name: self.name,
link_type: self.link_type,
state: self.state,
mtu: self.mtu,
master: self.master,
address: self.address,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(into = "AddressRepr", try_from = "AddressRepr"))]
#[derive(Debug, Clone)]
pub struct DeclaredAddress {
pub(crate) dev: String,
pub(crate) address: IpAddr,
pub(crate) prefix_len: u8,
}
#[cfg(feature = "serde")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct AddressRepr {
dev: String,
address: String,
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for DeclaredAddress {
fn schema_name() -> std::borrow::Cow<'static, str> {
AddressRepr::schema_name()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
AddressRepr::schema_id()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
AddressRepr::json_schema(g)
}
fn inline_schema() -> bool {
AddressRepr::inline_schema()
}
}
#[cfg(feature = "serde")]
impl From<DeclaredAddress> for AddressRepr {
fn from(a: DeclaredAddress) -> Self {
AddressRepr {
dev: a.dev,
address: format!("{}/{}", a.address, a.prefix_len),
}
}
}
#[cfg(feature = "serde")]
impl TryFrom<AddressRepr> for DeclaredAddress {
type Error = AddressParseError;
fn try_from(r: AddressRepr) -> Result<Self, Self::Error> {
DeclaredAddress::parse(&r.dev, &r.address)
}
}
impl DeclaredAddress {
pub fn parse(dev: &str, addr: &str) -> Result<Self, AddressParseError> {
let (ip_str, prefix_str) = addr
.split_once('/')
.ok_or_else(|| AddressParseError::MissingPrefix(addr.to_string()))?;
let address: IpAddr = ip_str
.parse()
.map_err(|_| AddressParseError::InvalidAddress(ip_str.to_string()))?;
let prefix_len: u8 = prefix_str
.parse()
.map_err(|_| AddressParseError::InvalidPrefix(prefix_str.to_string()))?;
let max_prefix = if address.is_ipv4() { 32 } else { 128 };
if prefix_len > max_prefix {
return Err(AddressParseError::PrefixTooLarge {
prefix: prefix_len,
max: max_prefix,
});
}
Ok(Self {
dev: dev.to_string(),
address,
prefix_len,
})
}
pub fn dev(&self) -> &str {
&self.dev
}
pub fn address(&self) -> IpAddr {
self.address
}
pub fn prefix_len(&self) -> u8 {
self.prefix_len
}
pub fn is_ipv4(&self) -> bool {
self.address.is_ipv4()
}
pub fn is_ipv6(&self) -> bool {
self.address.is_ipv6()
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum AddressParseError {
#[error("address missing prefix: {0} (expected format: 192.168.1.1/24)")]
MissingPrefix(String),
#[error("invalid IP address: {0}")]
InvalidAddress(String),
#[error("invalid prefix length: {0}")]
InvalidPrefix(String),
#[error("prefix length {prefix} exceeds maximum {max}")]
PrefixTooLarge { prefix: u8, max: u8 },
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "serde", serde(into = "RouteRepr", try_from = "RouteRepr"))]
#[derive(Debug, Clone)]
pub struct DeclaredRoute {
pub(crate) destination: IpAddr,
pub(crate) prefix_len: u8,
pub(crate) gateway: Option<IpAddr>,
pub(crate) dev: Option<String>,
pub(crate) metric: Option<u32>,
pub(crate) table: Option<u32>,
pub(crate) route_type: DeclaredRouteType,
}
#[cfg(feature = "serde")]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[derive(serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "kebab-case")]
struct RouteRepr {
destination: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
gateway: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
dev: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
metric: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
table: Option<u32>,
#[serde(
rename = "type",
default,
skip_serializing_if = "DeclaredRouteType::is_unicast"
)]
route_type: DeclaredRouteType,
}
#[cfg(feature = "schemars")]
impl schemars::JsonSchema for DeclaredRoute {
fn schema_name() -> std::borrow::Cow<'static, str> {
RouteRepr::schema_name()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
RouteRepr::schema_id()
}
fn json_schema(g: &mut schemars::SchemaGenerator) -> schemars::Schema {
RouteRepr::json_schema(g)
}
fn inline_schema() -> bool {
RouteRepr::inline_schema()
}
}
#[cfg(feature = "serde")]
impl From<DeclaredRoute> for RouteRepr {
fn from(r: DeclaredRoute) -> Self {
let destination = if r.prefix_len == 0 && r.destination.is_unspecified() {
"default".to_string()
} else {
format!("{}/{}", r.destination, r.prefix_len)
};
RouteRepr {
destination,
gateway: r.gateway.map(|g| g.to_string()),
dev: r.dev,
metric: r.metric,
table: r.table,
route_type: r.route_type,
}
}
}
#[cfg(feature = "serde")]
impl TryFrom<RouteRepr> for DeclaredRoute {
type Error = RouteParseError;
fn try_from(r: RouteRepr) -> Result<Self, Self::Error> {
let gateway = match r.gateway.as_deref() {
Some(g) => Some(
g.parse::<IpAddr>()
.map_err(|_| RouteParseError::InvalidGateway(g.to_string()))?,
),
None => None,
};
let (destination, prefix_len) = if r.destination == "default" {
if matches!(gateway, Some(IpAddr::V6(_))) {
(IpAddr::V6(std::net::Ipv6Addr::UNSPECIFIED), 0)
} else {
(IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), 0)
}
} else {
let (ip_str, prefix_str) = r
.destination
.split_once('/')
.ok_or_else(|| RouteParseError::MissingPrefix(r.destination.clone()))?;
let dst: IpAddr = ip_str
.parse()
.map_err(|_| RouteParseError::InvalidDestination(ip_str.to_string()))?;
let plen: u8 = prefix_str
.parse()
.map_err(|_| RouteParseError::InvalidPrefix(prefix_str.to_string()))?;
let max = if dst.is_ipv4() { 32 } else { 128 };
if plen > max {
return Err(RouteParseError::PrefixTooLarge { prefix: plen, max });
}
(dst, plen)
};
Ok(DeclaredRoute {
destination,
prefix_len,
gateway,
dev: r.dev,
metric: r.metric,
table: r.table,
route_type: r.route_type,
})
}
}
impl DeclaredRoute {
pub fn destination(&self) -> IpAddr {
self.destination
}
pub fn prefix_len(&self) -> u8 {
self.prefix_len
}
pub fn gateway(&self) -> Option<IpAddr> {
self.gateway
}
pub fn dev(&self) -> Option<&str> {
self.dev.as_deref()
}
pub fn metric(&self) -> Option<u32> {
self.metric
}
pub fn table(&self) -> Option<u32> {
self.table
}
pub fn route_type(&self) -> DeclaredRouteType {
self.route_type
}
pub fn is_ipv4(&self) -> bool {
self.destination.is_ipv4()
}
pub fn is_ipv6(&self) -> bool {
self.destination.is_ipv6()
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum DeclaredRouteType {
#[default]
Unicast,
Blackhole,
Unreachable,
Prohibit,
}
impl DeclaredRouteType {
#[cfg(feature = "serde")]
fn is_unicast(&self) -> bool {
matches!(self, Self::Unicast)
}
}
#[derive(Debug, Clone, thiserror::Error)]
#[non_exhaustive]
pub enum RouteParseError {
#[error("destination missing prefix: {0} (expected format: 10.0.0.0/8)")]
MissingPrefix(String),
#[error("invalid destination address: {0}")]
InvalidDestination(String),
#[error("invalid prefix length: {0}")]
InvalidPrefix(String),
#[error("prefix length {prefix} exceeds maximum {max}")]
PrefixTooLarge { prefix: u8, max: u8 },
#[error("invalid gateway address: {0}")]
InvalidGateway(String),
}
#[derive(Debug)]
#[must_use = "builders do nothing unless used"]
pub struct RouteBuilder {
destination: IpAddr,
prefix_len: u8,
gateway: Option<IpAddr>,
dev: Option<String>,
metric: Option<u32>,
table: Option<u32>,
route_type: DeclaredRouteType,
}
impl RouteBuilder {
pub fn default_v4() -> Self {
Self::new("0.0.0.0/0").expect("0.0.0.0/0 is a valid IPv4 CIDR")
}
pub fn default_v6() -> Self {
Self::new("::/0").expect("::/0 is a valid IPv6 CIDR")
}
fn new(dst: &str) -> Result<Self, RouteParseError> {
let (ip_str, prefix_str) = dst
.split_once('/')
.ok_or_else(|| RouteParseError::MissingPrefix(dst.to_string()))?;
let destination: IpAddr = ip_str
.parse()
.map_err(|_| RouteParseError::InvalidDestination(ip_str.to_string()))?;
let prefix_len: u8 = prefix_str
.parse()
.map_err(|_| RouteParseError::InvalidPrefix(prefix_str.to_string()))?;
let max_prefix = if destination.is_ipv4() { 32 } else { 128 };
if prefix_len > max_prefix {
return Err(RouteParseError::PrefixTooLarge {
prefix: prefix_len,
max: max_prefix,
});
}
Ok(Self {
destination,
prefix_len,
gateway: None,
dev: None,
metric: None,
table: None,
route_type: DeclaredRouteType::default(),
})
}
pub fn via(mut self, gateway: &str) -> Self {
if let Ok(addr) = gateway.parse() {
self.gateway = Some(addr);
}
self
}
pub fn dev(mut self, dev: &str) -> Self {
self.dev = Some(dev.to_string());
self
}
pub fn metric(mut self, metric: u32) -> Self {
self.metric = Some(metric);
self
}
pub fn table(mut self, table: u32) -> Self {
self.table = Some(table);
self
}
pub fn blackhole(mut self) -> Self {
self.route_type = DeclaredRouteType::Blackhole;
self
}
pub fn unreachable(mut self) -> Self {
self.route_type = DeclaredRouteType::Unreachable;
self
}
pub fn prohibit(mut self) -> Self {
self.route_type = DeclaredRouteType::Prohibit;
self
}
fn build(self) -> DeclaredRoute {
DeclaredRoute {
destination: self.destination,
prefix_len: self.prefix_len,
gateway: self.gateway,
dev: self.dev,
metric: self.metric,
table: self.table,
route_type: self.route_type,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "kebab-case"))]
#[derive(Debug, Clone)]
pub struct DeclaredQdisc {
pub(crate) dev: String,
#[cfg_attr(feature = "serde", serde(default))]
pub(crate) parent: QdiscParent,
pub(crate) qdisc_type: DeclaredQdiscType,
}
impl DeclaredQdisc {
pub fn dev(&self) -> &str {
&self.dev
}
pub fn parent(&self) -> QdiscParent {
self.parent
}
pub fn effective_parent(&self) -> QdiscParent {
self.qdisc_type.required_parent().unwrap_or(self.parent)
}
pub fn qdisc_type(&self) -> &DeclaredQdiscType {
&self.qdisc_type
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[non_exhaustive]
pub enum QdiscParent {
#[default]
Root,
Ingress,
}
impl DeclaredQdiscType {
pub(crate) fn netem_config(&self) -> Option<crate::netlink::tc::NetemConfig> {
use crate::netlink::tc::NetemConfig;
use crate::util::{Percent, Rate};
let DeclaredQdiscType::Netem {
delay_us,
jitter_us,
loss_percent,
limit,
duplicate_percent,
corrupt_percent,
reorder_percent,
loss_correlation,
delay_correlation,
rate_bps,
duplicate_correlation,
corrupt_correlation,
reorder_correlation,
gap,
} = self
else {
return None;
};
let mut cfg = NetemConfig::new();
if let Some(d) = delay_us {
cfg = cfg.delay(Duration::from_micros(u64::from(*d)));
}
if let Some(j) = jitter_us {
cfg = cfg.jitter(Duration::from_micros(u64::from(*j)));
}
if let Some(l) = loss_percent {
cfg = cfg.loss(Percent::new(*l));
}
if let Some(lim) = limit {
cfg = cfg.limit(*lim);
}
if let Some(d) = duplicate_percent {
cfg = cfg.duplicate(Percent::new(*d));
}
if let Some(c) = corrupt_percent {
cfg = cfg.corrupt(Percent::new(*c));
}
if let Some(r) = reorder_percent {
cfg = cfg.reorder(Percent::new(*r));
}
if let Some(corr) = loss_correlation {
cfg = cfg.loss_correlation(Percent::new(*corr));
}
if let Some(corr) = delay_correlation {
cfg = cfg.delay_correlation(Percent::new(*corr));
}
if let Some(bps) = rate_bps {
cfg = cfg.rate(Rate::bytes_per_sec(*bps));
}
if let Some(corr) = duplicate_correlation {
cfg = cfg.duplicate_correlation(Percent::new(*corr));
}
if let Some(corr) = corrupt_correlation {
cfg = cfg.corrupt_correlation(Percent::new(*corr));
}
if let Some(corr) = reorder_correlation {
cfg = cfg.reorder_correlation(Percent::new(*corr));
}
if let Some(g) = gap {
cfg = cfg.gap(*g);
}
Some(cfg.build())
}
pub(crate) fn tbf_config(&self) -> Option<crate::netlink::tc::TbfConfig> {
use crate::netlink::tc::TbfConfig;
use crate::util::{Bytes, Rate};
let Self::Tbf {
rate_bps,
burst_bytes,
limit_bytes,
peakrate_bps,
mtu,
..
} = self
else {
return None;
};
let mut cfg = TbfConfig::new()
.rate(Rate::bytes_per_sec(*rate_bps))
.burst(Bytes::new(u64::from(*burst_bytes)));
if let Some(limit) = limit_bytes {
cfg = cfg.limit(Bytes::new(u64::from(*limit)));
}
if let Some(peak) = peakrate_bps {
cfg = cfg.peakrate(Rate::bytes_per_sec(*peak));
}
if let Some(m) = mtu {
cfg = cfg.mtu(*m);
}
Some(cfg.build())
}
pub(crate) fn htb_config(&self) -> Option<crate::netlink::tc::HtbQdiscConfig> {
let Self::Htb { default_class } = self else {
return None;
};
Some(crate::netlink::tc::HtbQdiscConfig::new().default_class(*default_class))
}
pub(crate) fn fq_codel_config(
&self,
creating: bool,
) -> Option<crate::netlink::tc::FqCodelConfig> {
let Self::FqCodel {
limit,
target_us,
interval_us,
flows,
quantum,
ecn,
..
} = self
else {
return None;
};
let mut cfg = crate::netlink::tc::FqCodelConfig::new();
if let Some(lim) = limit {
cfg = cfg.limit(*lim);
}
if let Some(t) = target_us {
cfg = cfg.target(Duration::from_micros(u64::from(*t)));
}
if let Some(i) = interval_us {
cfg = cfg.interval(Duration::from_micros(u64::from(*i)));
}
if let Some(f) = flows
&& creating
{
cfg = cfg.flows(*f);
}
if let Some(q) = quantum {
cfg = cfg.quantum(*q);
}
if let Some(e) = ecn {
cfg = cfg.ecn(*e);
}
Some(cfg)
}
pub(crate) fn sfq_config(&self) -> Option<crate::netlink::tc::SfqConfig> {
let Self::Sfq {
perturb_secs,
limit,
quantum,
..
} = self
else {
return None;
};
let mut cfg = crate::netlink::tc::SfqConfig::new();
if let Some(p) = perturb_secs {
cfg = cfg.perturb(i32::try_from(*p).unwrap_or(i32::MAX));
}
if let Some(l) = limit {
cfg = cfg.limit(*l);
}
if let Some(q) = quantum {
cfg = cfg.quantum(*q);
}
Some(cfg)
}
pub(crate) fn prio_config(&self) -> Option<crate::netlink::tc::PrioConfig> {
let Self::Prio { bands } = self else {
return None;
};
let mut cfg = crate::netlink::tc::PrioConfig::new();
if let Some(b) = bands {
cfg = cfg.bands(i32::from(*b));
}
Some(cfg)
}
}
impl DeclaredQdiscType {
pub(crate) fn required_parent(&self) -> Option<QdiscParent> {
match self {
DeclaredQdiscType::Ingress | DeclaredQdiscType::Clsact => Some(QdiscParent::Ingress),
_ => None,
}
}
}
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
#[cfg_attr(feature = "schemars", derive(schemars::JsonSchema))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum DeclaredQdiscType {
#[non_exhaustive]
Netem {
delay_us: Option<u32>,
jitter_us: Option<u32>,
loss_percent: Option<f64>,
limit: Option<u32>,
duplicate_percent: Option<f64>,
corrupt_percent: Option<f64>,
reorder_percent: Option<f64>,
loss_correlation: Option<f64>,
delay_correlation: Option<f64>,
rate_bps: Option<u64>,
duplicate_correlation: Option<f64>,
corrupt_correlation: Option<f64>,
reorder_correlation: Option<f64>,
gap: Option<u32>,
},
Htb { default_class: u32 },
#[non_exhaustive]
FqCodel {
limit: Option<u32>,
target_us: Option<u32>,
interval_us: Option<u32>,
flows: Option<u32>,
quantum: Option<u32>,
ecn: Option<bool>,
},
#[non_exhaustive]
Tbf {
rate_bps: u64,
burst_bytes: u32,
limit_bytes: Option<u32>,
peakrate_bps: Option<u64>,
mtu: Option<u32>,
},
#[non_exhaustive]
Sfq {
perturb_secs: Option<u32>,
limit: Option<u32>,
quantum: Option<u32>,
},
Prio { bands: Option<u8> },
Ingress,
Clsact,
}
impl DeclaredQdiscType {
pub fn kind(&self) -> &str {
match self {
Self::Netem { .. } => "netem",
Self::Htb { .. } => "htb",
Self::FqCodel { .. } => "fq_codel",
Self::Tbf { .. } => "tbf",
Self::Sfq { .. } => "sfq",
Self::Prio { .. } => "prio",
Self::Ingress => "ingress",
Self::Clsact => "clsact",
}
}
}
#[derive(Debug)]
#[must_use = "builders do nothing unless used"]
pub struct QdiscBuilder {
dev: String,
parent: QdiscParent,
qdisc_type: Option<DeclaredQdiscType>,
}
impl QdiscBuilder {
fn new(dev: &str) -> Self {
Self {
dev: dev.to_string(),
parent: QdiscParent::Root,
qdisc_type: None,
}
}
pub fn netem(mut self) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::Netem {
delay_us: None,
jitter_us: None,
loss_percent: None,
limit: None,
duplicate_percent: None,
corrupt_percent: None,
reorder_percent: None,
loss_correlation: None,
delay_correlation: None,
rate_bps: None,
duplicate_correlation: None,
corrupt_correlation: None,
reorder_correlation: None,
gap: None,
});
self
}
pub fn delay_ms(mut self, ms: u32) -> Self {
if let Some(DeclaredQdiscType::Netem { delay_us, .. }) = &mut self.qdisc_type {
*delay_us = Some(ms.saturating_mul(1000));
}
self
}
pub fn delay_us(mut self, us: u32) -> Self {
if let Some(DeclaredQdiscType::Netem { delay_us, .. }) = &mut self.qdisc_type {
*delay_us = Some(us);
}
self
}
pub fn delay(self, delay: Duration) -> Self {
self.delay_us(delay.as_micros().min(u32::MAX as u128) as u32)
}
pub fn jitter_ms(mut self, ms: u32) -> Self {
if let Some(DeclaredQdiscType::Netem { jitter_us, .. }) = &mut self.qdisc_type {
*jitter_us = Some(ms.saturating_mul(1000));
}
self
}
pub fn jitter_us(mut self, us: u32) -> Self {
if let Some(DeclaredQdiscType::Netem { jitter_us, .. }) = &mut self.qdisc_type {
*jitter_us = Some(us);
}
self
}
pub fn jitter(self, jitter: Duration) -> Self {
self.jitter_us(jitter.as_micros().min(u32::MAX as u128) as u32)
}
pub fn rate(mut self, rate: crate::util::Rate) -> Self {
if let Some(DeclaredQdiscType::Netem { rate_bps, .. }) = &mut self.qdisc_type {
*rate_bps = Some(rate.as_bytes_per_sec());
}
self
}
pub fn gap(mut self, packets: u32) -> Self {
if let Some(DeclaredQdiscType::Netem { gap, .. }) = &mut self.qdisc_type {
*gap = Some(packets);
}
self
}
pub fn loss_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { loss_percent, .. }) = &mut self.qdisc_type {
*loss_percent = Some(percent.as_percent());
}
self
}
pub fn duplicate_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { duplicate_percent, .. }) =
&mut self.qdisc_type
{
*duplicate_percent = Some(percent.as_percent());
}
self
}
pub fn corrupt_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { corrupt_percent, .. }) =
&mut self.qdisc_type
{
*corrupt_percent = Some(percent.as_percent());
}
self
}
pub fn reorder_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { reorder_percent, .. }) =
&mut self.qdisc_type
{
*reorder_percent = Some(percent.as_percent());
}
self
}
pub fn loss_correlation_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { loss_correlation, .. }) =
&mut self.qdisc_type
{
*loss_correlation = Some(percent.as_percent());
}
self
}
pub fn delay_correlation_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { delay_correlation, .. }) =
&mut self.qdisc_type
{
*delay_correlation = Some(percent.as_percent());
}
self
}
pub fn reorder_correlation_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { reorder_correlation, .. }) =
&mut self.qdisc_type
{
*reorder_correlation = Some(percent.as_percent());
}
self
}
pub fn duplicate_correlation_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { duplicate_correlation, .. }) =
&mut self.qdisc_type
{
*duplicate_correlation = Some(percent.as_percent());
}
self
}
pub fn corrupt_correlation_pct(mut self, percent: crate::util::Percent) -> Self {
if let Some(DeclaredQdiscType::Netem { corrupt_correlation, .. }) =
&mut self.qdisc_type
{
*corrupt_correlation = Some(percent.as_percent());
}
self
}
pub fn limit(mut self, packets: u32) -> Self {
match &mut self.qdisc_type {
Some(DeclaredQdiscType::Netem { limit, .. })
| Some(DeclaredQdiscType::FqCodel { limit, .. })
| Some(DeclaredQdiscType::Sfq { limit, .. }) => *limit = Some(packets),
_ => {}
}
self
}
pub fn target(mut self, target: Duration) -> Self {
if let Some(DeclaredQdiscType::FqCodel { target_us, .. }) = &mut self.qdisc_type {
*target_us = Some(target.as_micros().min(u128::from(u32::MAX)) as u32);
}
self
}
pub fn interval(mut self, interval: Duration) -> Self {
if let Some(DeclaredQdiscType::FqCodel { interval_us, .. }) = &mut self.qdisc_type {
*interval_us = Some(interval.as_micros().min(u128::from(u32::MAX)) as u32);
}
self
}
pub fn flows(mut self, flows: u32) -> Self {
if let Some(DeclaredQdiscType::FqCodel { flows: f, .. }) = &mut self.qdisc_type {
*f = Some(flows);
}
self
}
pub fn quantum(mut self, bytes: u32) -> Self {
match &mut self.qdisc_type {
Some(DeclaredQdiscType::FqCodel { quantum, .. })
| Some(DeclaredQdiscType::Sfq { quantum, .. }) => *quantum = Some(bytes),
_ => {}
}
self
}
pub fn ecn(mut self, enabled: bool) -> Self {
if let Some(DeclaredQdiscType::FqCodel { ecn, .. }) = &mut self.qdisc_type {
*ecn = Some(enabled);
}
self
}
pub fn perturb(mut self, period: Duration) -> Self {
if let Some(DeclaredQdiscType::Sfq { perturb_secs, .. }) = &mut self.qdisc_type {
*perturb_secs = Some(period.as_secs().min(u64::from(u32::MAX)) as u32);
}
self
}
pub fn bands(mut self, bands: u8) -> Self {
if let Some(DeclaredQdiscType::Prio { bands: b }) = &mut self.qdisc_type {
*b = Some(bands);
}
self
}
pub fn peakrate(mut self, rate: crate::util::Rate) -> Self {
if let Some(DeclaredQdiscType::Tbf { peakrate_bps, .. }) = &mut self.qdisc_type {
*peakrate_bps = Some(rate.as_bytes_per_sec());
}
self
}
pub fn mtu(mut self, bytes: u32) -> Self {
if let Some(DeclaredQdiscType::Tbf { mtu, .. }) = &mut self.qdisc_type {
*mtu = Some(bytes);
}
self
}
pub fn htb(mut self) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::Htb { default_class: 0 });
self
}
pub fn default_class(mut self, class: u32) -> Self {
if let Some(DeclaredQdiscType::Htb { default_class }) = &mut self.qdisc_type {
*default_class = class;
}
self
}
pub fn fq_codel(mut self) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::FqCodel {
limit: None,
target_us: None,
interval_us: None,
flows: None,
quantum: None,
ecn: None,
});
self
}
pub fn tbf(mut self, rate: crate::util::Rate, burst: crate::util::Bytes) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::Tbf {
rate_bps: rate.as_bytes_per_sec(),
burst_bytes: burst.as_u32_saturating(),
limit_bytes: None,
peakrate_bps: None,
mtu: None,
});
self
}
pub fn limit_bytes(mut self, limit: crate::util::Bytes) -> Self {
if let Some(DeclaredQdiscType::Tbf { limit_bytes, .. }) = &mut self.qdisc_type {
*limit_bytes = Some(limit.as_u32_saturating());
}
self
}
pub fn sfq(mut self) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::Sfq {
perturb_secs: None,
limit: None,
quantum: None,
});
self
}
pub fn prio(mut self) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::Prio { bands: None });
self
}
pub fn ingress(mut self) -> Self {
self.parent = QdiscParent::Ingress;
self.qdisc_type = Some(DeclaredQdiscType::Ingress);
self
}
pub fn clsact(mut self) -> Self {
self.parent = QdiscParent::Ingress;
self.qdisc_type = Some(DeclaredQdiscType::Clsact);
self
}
fn build(self) -> DeclaredQdisc {
DeclaredQdisc {
dev: self.dev,
parent: self.parent,
qdisc_type: self.qdisc_type.unwrap_or(DeclaredQdiscType::FqCodel {
limit: None,
target_us: None,
interval_us: None,
flows: None,
quantum: None,
ecn: None,
}),
}
}
}
#[cfg(test)]
mod plan_190_tests {
use super::*;
#[test]
fn vrf_builder_sets_table() {
let link = LinkBuilder::new("vrf-red").vrf(100).build();
match link.link_type {
DeclaredLinkType::Vrf { table } => assert_eq!(table, 100),
other => panic!("expected DeclaredLinkType::Vrf, got {other:?}"),
}
}
#[test]
fn vrf_kind_string_is_vrf() {
let lt = DeclaredLinkType::Vrf { table: 7 };
assert_eq!(lt.kind(), Some("vrf"));
}
#[test]
fn ovpn_builder_creates_ovpn_variant() {
let link = LinkBuilder::new("ovpn0").ovpn().build();
assert!(matches!(link.link_type, DeclaredLinkType::Ovpn));
}
#[test]
fn ovpn_kind_string_is_ovpn() {
assert_eq!(DeclaredLinkType::Ovpn.kind(), Some("ovpn"));
}
#[test]
fn netkit_builder_peer_carried_others_default_none() {
let link = LinkBuilder::new("nk0").netkit("nk1").build();
match link.link_type {
DeclaredLinkType::Netkit {
peer,
mode,
primary_policy,
peer_policy,
scrub,
peer_scrub,
} => {
assert_eq!(peer, "nk1");
assert!(mode.is_none());
assert!(primary_policy.is_none());
assert!(peer_policy.is_none());
assert!(scrub.is_none());
assert!(peer_scrub.is_none());
}
other => panic!("expected Netkit, got {other:?}"),
}
}
#[test]
fn netkit_builder_full_setter_chain() {
let link = LinkBuilder::new("nk0")
.netkit("nk1")
.netkit_mode(NetkitMode::L2)
.netkit_primary_policy(NetkitPolicy::Forward)
.netkit_peer_policy(NetkitPolicy::Blackhole)
.netkit_scrub(NetkitScrub::Default)
.netkit_peer_scrub(NetkitScrub::None)
.build();
match link.link_type {
DeclaredLinkType::Netkit {
peer,
mode,
primary_policy,
peer_policy,
scrub,
peer_scrub,
} => {
assert_eq!(peer, "nk1");
assert_eq!(mode, Some(NetkitMode::L2));
assert_eq!(primary_policy, Some(NetkitPolicy::Forward));
assert_eq!(peer_policy, Some(NetkitPolicy::Blackhole));
assert_eq!(scrub, Some(NetkitScrub::Default));
assert_eq!(peer_scrub, Some(NetkitScrub::None));
}
other => panic!("expected Netkit, got {other:?}"),
}
}
#[test]
fn netkit_kind_string_is_netkit() {
let lt = DeclaredLinkType::Netkit {
peer: "x".into(),
mode: None,
primary_policy: None,
peer_policy: None,
scrub: None,
peer_scrub: None,
};
assert_eq!(lt.kind(), Some("netkit"));
}
#[test]
fn bond_builder_defaults_all_new_knobs_to_none() {
let link = LinkBuilder::new("bond0").bond().build();
match link.link_type {
DeclaredLinkType::Bond {
ad_select,
lacp_rate,
downdelay,
updelay,
resend_igmp,
..
} => {
assert!(ad_select.is_none());
assert!(lacp_rate.is_none());
assert!(downdelay.is_none());
assert!(updelay.is_none());
assert!(resend_igmp.is_none());
}
other => panic!("expected Bond, got {other:?}"),
}
}
#[test]
fn bond_builder_all_5_setters_round_trip() {
let link = LinkBuilder::new("bond0")
.bond()
.bond_ad_select(BondAdSelect::Bandwidth)
.bond_lacp_rate(BondLacpRate::Fast)
.bond_downdelay(200)
.bond_updelay(500)
.bond_resend_igmp(3)
.build();
match link.link_type {
DeclaredLinkType::Bond {
ad_select,
lacp_rate,
downdelay,
updelay,
resend_igmp,
..
} => {
assert_eq!(ad_select, Some(BondAdSelect::Bandwidth));
assert_eq!(lacp_rate, Some(BondLacpRate::Fast));
assert_eq!(downdelay, Some(200));
assert_eq!(updelay, Some(500));
assert_eq!(resend_igmp, Some(3));
}
other => panic!("expected Bond, got {other:?}"),
}
}
#[test]
fn bond_setters_no_op_on_non_bond() {
let link = LinkBuilder::new("eth0")
.dummy()
.bond_ad_select(BondAdSelect::Stable)
.bond_lacp_rate(BondLacpRate::Slow)
.bond_downdelay(100)
.bond_updelay(100)
.bond_resend_igmp(1)
.build();
assert!(matches!(link.link_type, DeclaredLinkType::Dummy));
}
#[test]
fn vxlan_builder_defaults_to_none_for_new_knobs() {
let link = LinkBuilder::new("vx0").vxlan(42).build();
match link.link_type {
DeclaredLinkType::Vxlan {
vni,
remote,
local,
port,
underlay_dev,
} => {
assert_eq!(vni, 42);
assert!(remote.is_none());
assert!(local.is_none());
assert!(port.is_none());
assert!(underlay_dev.is_none());
}
other => panic!("expected Vxlan, got {other:?}"),
}
}
#[test]
fn vxlan_builder_local_port_underlay_round_trip() {
use std::net::Ipv4Addr;
let link = LinkBuilder::new("vx0")
.vxlan(100)
.vxlan_remote(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1)))
.vxlan_local(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2)))
.vxlan_port(4790)
.vxlan_underlay_dev("eth0")
.build();
match link.link_type {
DeclaredLinkType::Vxlan {
vni,
remote,
local,
port,
underlay_dev,
} => {
assert_eq!(vni, 100);
assert_eq!(remote, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 1))));
assert_eq!(local, Some(IpAddr::V4(Ipv4Addr::new(10, 0, 0, 2))));
assert_eq!(port, Some(4790));
assert_eq!(underlay_dev.as_deref(), Some("eth0"));
}
other => panic!("expected Vxlan, got {other:?}"),
}
}
#[test]
fn vxlan_setters_no_op_on_non_vxlan() {
use std::net::Ipv4Addr;
let link = LinkBuilder::new("eth0")
.dummy()
.vxlan_local(IpAddr::V4(Ipv4Addr::new(1, 2, 3, 4)))
.vxlan_port(4790)
.vxlan_underlay_dev("ignored")
.build();
assert!(matches!(link.link_type, DeclaredLinkType::Dummy));
}
#[test]
fn vlan_builder_protocol_defaults_to_none() {
let link = LinkBuilder::new("eth0.100").vlan("eth0", 100).build();
match link.link_type {
DeclaredLinkType::Vlan { protocol, .. } => assert!(protocol.is_none()),
other => panic!("expected Vlan, got {other:?}"),
}
}
#[test]
fn vlan_builder_protocol_setter_records_dot1ad() {
let link = LinkBuilder::new("eth0.100")
.vlan("eth0", 100)
.vlan_protocol(VlanProtocol::Dot1ad)
.build();
match link.link_type {
DeclaredLinkType::Vlan { protocol, .. } => {
assert_eq!(protocol, Some(VlanProtocol::Dot1ad));
}
other => panic!("expected Vlan, got {other:?}"),
}
}
#[test]
fn vlan_protocol_setter_no_op_on_non_vlan() {
let link = LinkBuilder::new("eth0")
.dummy()
.vlan_protocol(VlanProtocol::Dot1ad)
.build();
assert!(matches!(link.link_type, DeclaredLinkType::Dummy));
}
#[test]
fn vlan_protocol_wire_values() {
assert_eq!(VlanProtocol::Dot1q.as_u16(), 0x8100);
assert_eq!(VlanProtocol::Dot1ad.as_u16(), 0x88a8);
}
#[test]
fn vrf_in_network_config_carries_master_chain() {
let cfg = NetworkConfig::new()
.link("vrf-red", |b| b.vrf(100))
.link("eth0", |b| b.dummy().master("vrf-red"));
assert_eq!(cfg.links.len(), 2);
assert!(matches!(
cfg.links[0].link_type,
DeclaredLinkType::Vrf { table: 100 }
));
assert_eq!(cfg.links[1].master.as_deref(), Some("vrf-red"));
}
}
#[cfg(test)]
mod plan_228_tests {
use super::*;
use crate::util::Percent;
fn netem_loss(q: &DeclaredQdisc) -> Option<f64> {
match &q.qdisc_type {
DeclaredQdiscType::Netem { loss_percent, .. } => *loss_percent,
_ => None,
}
}
#[test]
fn loss_pct_stores_clamped_f64_internally() {
let q = QdiscBuilder::new("eth0").netem().loss_pct(Percent::new(1.5));
let built = q.build();
assert_eq!(netem_loss(&built), Some(1.5));
}
#[test]
fn loss_pct_clamps_supra_100() {
let q = QdiscBuilder::new("eth0")
.netem()
.loss_pct(Percent::new(150.0))
.build();
assert_eq!(netem_loss(&q), Some(100.0));
}
#[test]
fn loss_pct_clamps_negative() {
let q = QdiscBuilder::new("eth0")
.netem()
.loss_pct(Percent::new(-1.5))
.build();
assert_eq!(netem_loss(&q), Some(0.0));
}
#[test]
fn loss_pct_handles_nan() {
let q = QdiscBuilder::new("eth0")
.netem()
.loss_pct(Percent::new(f64::NAN))
.build();
let v = netem_loss(&q).expect("loss_percent set");
assert!(v.is_nan(), "Percent::new(NaN) → stored NaN (documented)");
}
#[test]
fn loss_pct_handles_infinity() {
let q = QdiscBuilder::new("eth0")
.netem()
.loss_pct(Percent::new(f64::INFINITY))
.build();
assert_eq!(netem_loss(&q), Some(100.0));
}
#[test]
fn loss_pct_from_fraction_distinguishes_units() {
let q = QdiscBuilder::new("eth0")
.netem()
.loss_pct(Percent::from_fraction(0.015))
.build();
assert_eq!(netem_loss(&q), Some(1.5));
}
fn netem_field<F>(q: &DeclaredQdisc, project: F) -> Option<f64>
where
F: Fn(&DeclaredQdiscType) -> Option<f64>,
{
project(&q.qdisc_type)
}
#[test]
fn duplicate_pct_stores_percent() {
let q = QdiscBuilder::new("eth0")
.netem()
.duplicate_pct(Percent::new(2.5))
.build();
let v = netem_field(&q, |t| match t {
DeclaredQdiscType::Netem { duplicate_percent, .. } => *duplicate_percent,
_ => None,
});
assert_eq!(v, Some(2.5));
}
#[test]
fn corrupt_pct_stores_percent() {
let q = QdiscBuilder::new("eth0")
.netem()
.corrupt_pct(Percent::new(0.75))
.build();
let v = netem_field(&q, |t| match t {
DeclaredQdiscType::Netem { corrupt_percent, .. } => *corrupt_percent,
_ => None,
});
assert_eq!(v, Some(0.75));
}
#[test]
fn reorder_pct_stores_percent() {
let q = QdiscBuilder::new("eth0")
.netem()
.reorder_pct(Percent::new(5.0))
.build();
let v = netem_field(&q, |t| match t {
DeclaredQdiscType::Netem { reorder_percent, .. } => *reorder_percent,
_ => None,
});
assert_eq!(v, Some(5.0));
}
#[test]
fn loss_correlation_pct_stores_percent() {
let q = QdiscBuilder::new("eth0")
.netem()
.loss_correlation_pct(Percent::new(25.0))
.build();
let v = netem_field(&q, |t| match t {
DeclaredQdiscType::Netem { loss_correlation, .. } => *loss_correlation,
_ => None,
});
assert_eq!(v, Some(25.0));
}
#[test]
fn delay_correlation_pct_stores_percent() {
let q = QdiscBuilder::new("eth0")
.netem()
.delay_correlation_pct(Percent::new(50.0))
.build();
let v = netem_field(&q, |t| match t {
DeclaredQdiscType::Netem { delay_correlation, .. } => *delay_correlation,
_ => None,
});
assert_eq!(v, Some(50.0));
}
#[test]
fn all_netem_setters_clamp_via_percent() {
let q = QdiscBuilder::new("eth0")
.netem()
.duplicate_pct(Percent::new(200.0)) .corrupt_pct(Percent::new(-10.0)) .reorder_pct(Percent::new(50.0))
.build();
let dup = netem_field(&q, |t| match t {
DeclaredQdiscType::Netem { duplicate_percent, .. } => *duplicate_percent,
_ => None,
});
let cor = netem_field(&q, |t| match t {
DeclaredQdiscType::Netem { corrupt_percent, .. } => *corrupt_percent,
_ => None,
});
assert_eq!(dup, Some(100.0));
assert_eq!(cor, Some(0.0));
}
fn netem_of(q: &DeclaredQdisc) -> &DeclaredQdiscType {
assert!(matches!(q.qdisc_type, DeclaredQdiscType::Netem { .. }));
&q.qdisc_type
}
#[test]
fn rate_is_stored_in_bytes_per_second_like_netem_config() {
use crate::util::Rate;
let q = QdiscBuilder::new("eth0").netem().rate(Rate::mbit(100)).build();
let DeclaredQdiscType::Netem { rate_bps, .. } = netem_of(&q) else { unreachable!() };
assert_eq!(*rate_bps, Some(12_500_000), "100 Mbit/s is 12.5 MB/s");
let cfg = q.qdisc_type.netem_config().unwrap();
assert_eq!(cfg.rate, Some(Rate::mbit(100)));
}
#[test]
fn tbf_takes_typed_units_and_lowers_to_tbf_config() {
use crate::netlink::tc::TbfConfig;
use crate::util::{Bytes, Rate};
let q = QdiscBuilder::new("eth0")
.tbf(Rate::mbit(100), Bytes::kib(32))
.limit_bytes(Bytes::kib(64))
.build();
let DeclaredQdiscType::Tbf {
rate_bps,
burst_bytes,
limit_bytes,
..
} = &q.qdisc_type
else {
panic!("tbf");
};
assert_eq!(*rate_bps, 12_500_000, "100 Mbit/s is 12.5 MB/s — bytes, not bits");
assert_eq!(*burst_bytes, 32_768);
assert_eq!(*limit_bytes, Some(65_536));
let lowered = q.qdisc_type.tbf_config().unwrap();
let by_hand = TbfConfig::new()
.rate(Rate::mbit(100))
.burst(Bytes::kib(32))
.limit(Bytes::kib(64))
.build();
assert_eq!(format!("{lowered:?}"), format!("{by_hand:?}"));
assert_eq!(lowered.rate, Rate::mbit(100));
let q = QdiscBuilder::new("eth0").tbf(Rate::kbit(512), Bytes::kb(10)).build();
let lowered = q.qdisc_type.tbf_config().unwrap();
assert_eq!(lowered.limit, Bytes::ZERO);
assert!(QdiscBuilder::new("eth0").netem().build().qdisc_type.tbf_config().is_none());
let q = QdiscBuilder::new("eth0").htb().limit_bytes(Bytes::kib(1)).build();
assert!(matches!(q.qdisc_type, DeclaredQdiscType::Htb { .. }));
}
#[test]
fn fq_codel_knobs_reach_the_lowered_config() {
use crate::netlink::tc::FqCodelConfig;
let q = QdiscBuilder::new("eth0")
.fq_codel()
.limit(1200)
.target(Duration::from_millis(5))
.interval(Duration::from_millis(100))
.flows(1024)
.quantum(300)
.ecn(true)
.build();
let lowered = q.qdisc_type.fq_codel_config(true).unwrap();
let by_hand = FqCodelConfig::new()
.limit(1200)
.target(Duration::from_millis(5))
.interval(Duration::from_millis(100))
.flows(1024)
.quantum(300)
.ecn(true);
assert_eq!(format!("{lowered:?}"), format!("{by_hand:?}"));
let changing = q.qdisc_type.fq_codel_config(false).unwrap();
assert_eq!(changing.flows, None, "flows must not ride on a change");
let without_flows = FqCodelConfig::new()
.limit(1200)
.target(Duration::from_millis(5))
.interval(Duration::from_millis(100))
.quantum(300)
.ecn(true);
assert_eq!(format!("{changing:?}"), format!("{without_flows:?}"));
}
#[test]
fn sfq_knobs_reach_the_lowered_config() {
use crate::netlink::tc::SfqConfig;
let q = QdiscBuilder::new("eth0")
.sfq()
.perturb(Duration::from_secs(10))
.limit(200)
.quantum(1514)
.build();
let lowered = q.qdisc_type.sfq_config().unwrap();
let by_hand = SfqConfig::new().perturb(10).limit(200).quantum(1514);
assert_eq!(format!("{lowered:?}"), format!("{by_hand:?}"));
}
#[test]
fn sfq_perturb_truncates_to_whole_seconds() {
let q = QdiscBuilder::new("eth0")
.sfq()
.perturb(Duration::from_millis(1_500))
.build();
let DeclaredQdiscType::Sfq { perturb_secs, .. } = &q.qdisc_type else {
panic!("sfq");
};
assert_eq!(*perturb_secs, Some(1));
}
#[test]
fn prio_bands_reach_the_lowered_config() {
let q = QdiscBuilder::new("eth0").prio().bands(4).build();
let lowered = q.qdisc_type.prio_config().unwrap();
assert_eq!(lowered.bands, 4);
let bare = QdiscBuilder::new("eth0").prio().build();
assert_eq!(bare.qdisc_type.prio_config().unwrap().bands, 3);
}
#[test]
fn tbf_peakrate_and_mtu_reach_the_lowered_config() {
use crate::netlink::tc::TbfConfig;
use crate::util::{Bytes, Rate};
let q = QdiscBuilder::new("eth0")
.tbf(Rate::mbit(100), Bytes::kib(32))
.peakrate(Rate::mbit(200))
.mtu(1600)
.build();
let lowered = q.qdisc_type.tbf_config().unwrap();
let by_hand = TbfConfig::new()
.rate(Rate::mbit(100))
.burst(Bytes::kib(32))
.peakrate(Rate::mbit(200))
.mtu(1600)
.build();
assert_eq!(format!("{lowered:?}"), format!("{by_hand:?}"));
}
#[test]
fn the_new_setters_are_no_ops_on_other_kinds() {
use crate::util::{Bytes, Rate};
let q = QdiscBuilder::new("eth0")
.htb()
.flows(8)
.ecn(true)
.bands(4)
.peakrate(Rate::mbit(1))
.mtu(9000)
.target(Duration::from_millis(5))
.perturb(Duration::from_secs(1))
.build();
assert!(matches!(q.qdisc_type, DeclaredQdiscType::Htb { .. }));
let netem = QdiscBuilder::new("eth0").netem().limit(50).build();
let DeclaredQdiscType::Netem { limit, .. } = &netem.qdisc_type else {
panic!("netem");
};
assert_eq!(*limit, Some(50));
let tbf = QdiscBuilder::new("eth0")
.tbf(Rate::mbit(1), Bytes::kib(4))
.limit(50)
.build();
let DeclaredQdiscType::Tbf { limit_bytes, .. } = &tbf.qdisc_type else {
panic!("tbf");
};
assert_eq!(
*limit_bytes, None,
"tbf's limit is a byte count and has its own setter; the packet \
`limit` must not land on it"
);
}
#[test]
fn duration_setters_keep_sub_millisecond_precision() {
let q = QdiscBuilder::new("eth0")
.netem()
.delay(Duration::from_micros(1_500))
.jitter(Duration::from_micros(250))
.build();
let DeclaredQdiscType::Netem { delay_us, jitter_us, .. } = netem_of(&q) else {
unreachable!()
};
assert_eq!((*delay_us, *jitter_us), (Some(1_500), Some(250)));
let cfg = q.qdisc_type.netem_config().unwrap();
assert_eq!(cfg.delay, Some(Duration::from_micros(1_500)));
assert_eq!(cfg.jitter, Some(Duration::from_micros(250)));
let q = QdiscBuilder::new("eth0").netem().jitter_us(250).build();
let DeclaredQdiscType::Netem { jitter_us, .. } = netem_of(&q) else { unreachable!() };
assert_eq!(*jitter_us, Some(250));
}
#[test]
fn millisecond_setters_saturate_instead_of_wrapping() {
let q = QdiscBuilder::new("eth0")
.netem()
.delay_ms(u32::MAX)
.jitter_ms(u32::MAX)
.delay(Duration::from_secs(1 << 40))
.build();
let DeclaredQdiscType::Netem { delay_us, jitter_us, .. } = netem_of(&q) else {
unreachable!()
};
assert_eq!((*delay_us, *jitter_us), (Some(u32::MAX), Some(u32::MAX)));
}
#[test]
fn every_netem_config_knob_has_a_declarative_setter() {
use crate::netlink::tc::NetemConfig;
use crate::util::Rate;
let q = QdiscBuilder::new("eth0")
.netem()
.delay(Duration::from_millis(20))
.jitter(Duration::from_micros(250))
.delay_correlation_pct(Percent::new(25.0))
.loss_pct(Percent::new(1.0))
.loss_correlation_pct(Percent::new(10.0))
.duplicate_pct(Percent::new(0.5))
.duplicate_correlation_pct(Percent::new(5.0))
.corrupt_pct(Percent::new(0.1))
.corrupt_correlation_pct(Percent::new(2.0))
.reorder_pct(Percent::new(3.0))
.reorder_correlation_pct(Percent::new(50.0))
.gap(5)
.rate(Rate::mbit(100))
.limit(2000)
.build();
let lowered = q.qdisc_type.netem_config().unwrap();
let by_hand = NetemConfig::new()
.delay(Duration::from_millis(20))
.jitter(Duration::from_micros(250))
.delay_correlation(Percent::new(25.0))
.loss(Percent::new(1.0))
.loss_correlation(Percent::new(10.0))
.duplicate(Percent::new(0.5))
.duplicate_correlation(Percent::new(5.0))
.corrupt(Percent::new(0.1))
.corrupt_correlation(Percent::new(2.0))
.reorder(Percent::new(3.0))
.reorder_correlation(Percent::new(50.0))
.gap(5)
.rate(Rate::mbit(100))
.limit(2000)
.build();
assert_eq!(format!("{lowered:?}"), format!("{by_hand:?}"));
assert!(QdiscBuilder::new("eth0").htb().build().qdisc_type.netem_config().is_none());
}
}
#[cfg(all(test, feature = "serde"))]
mod serde_roundtrip_tests {
use super::*;
use crate::netlink::link::VlanProtocol;
fn assert_roundtrips(cfg: &NetworkConfig) {
let json = cfg.to_json_string().expect("serialize");
let back = NetworkConfig::from_json_str(&json).expect("deserialize");
let json2 = back.to_json_string().expect("re-serialize");
assert_eq!(json, json2, "round-trip changed the document:\n{json}\n{json2}");
}
#[test]
fn full_config_roundtrips() {
let cfg = NetworkConfig::new()
.link("br0", |l| l.bridge().up().mtu(1500))
.link("eth0.42", |l| {
l.vlan("eth0", 42).vlan_protocol(VlanProtocol::Dot1ad)
})
.link("bond0", |l| l.bond().bond_mode(BondMode::Ieee802_3ad))
.link("dummy0", |l| l.dummy().address([0xaa, 0xbb, 0xcc, 0x11, 0x22, 0x33]))
.address("br0", "10.0.0.1/24")
.expect("addr")
.address("br0", "2001:db8::1/64")
.expect("addr6")
.route("0.0.0.0/0", |r| r.via("10.0.0.254"))
.expect("route")
.route("10.10.0.0/16", |r| r.blackhole().metric(100))
.expect("blackhole")
.qdisc("eth0", |q| q.netem().delay_ms(100));
assert_roundtrips(&cfg);
}
#[test]
fn netem_rate_and_correlations_roundtrip_and_are_optional() {
use crate::util::{Percent, Rate};
let cfg = NetworkConfig::new().qdisc("eth0", |q| {
q.netem()
.delay(Duration::from_micros(1_500))
.jitter(Duration::from_micros(250))
.rate(Rate::mbit(100))
.reorder_pct(Percent::new(3.0))
.reorder_correlation_pct(Percent::new(50.0))
.gap(5)
});
assert_roundtrips(&cfg);
let json = cfg.to_json_string().unwrap();
assert!(json.contains(r#""rate_bps":12500000"#), "{json}");
let old = r#"{"qdiscs":[{"dev":"eth0","qdisc-type":{"netem":{"delay_us":100000}}}]}"#;
let back = NetworkConfig::from_json_str(old).unwrap();
let DeclaredQdiscType::Netem { rate_bps, gap, delay_us, .. } = &back.qdiscs()[0].qdisc_type
else {
panic!("netem");
};
assert_eq!((*rate_bps, *gap, *delay_us), (None, None, Some(100_000)));
}
#[test]
fn tbf_serde_shape_is_still_bytes_per_second_integers() {
use crate::util::{Bytes, Rate};
let cfg = NetworkConfig::new()
.qdisc("eth0", |q| q.tbf(Rate::mbit(100), Bytes::kib(32)).limit_bytes(Bytes::kib(64)));
assert_roundtrips(&cfg);
let json = cfg.to_json_string().unwrap();
assert!(
json.contains(
r#""tbf":{"rate_bps":12500000,"burst_bytes":32768,"limit_bytes":65536,"peakrate_bps":null,"mtu":null}"#
),
"{json}"
);
let pre_0_28 =
r#"{"qdiscs":[{"dev":"eth0","qdisc-type":{"tbf":{"rate_bps":1,"burst_bytes":2}}}]}"#;
let back = NetworkConfig::from_json_str(pre_0_28).unwrap();
let DeclaredQdiscType::Tbf { peakrate_bps, mtu, .. } = &back.qdiscs()[0].qdisc_type else {
panic!("tbf");
};
assert_eq!((*peakrate_bps, *mtu), (None, None));
}
#[test]
fn address_roundtrips_as_cidr_string() {
let cfg = NetworkConfig::new()
.address("eth0", "192.168.1.5/24")
.unwrap();
let json = cfg.to_json_string().unwrap();
assert!(json.contains(r#""address":"192.168.1.5/24""#), "{json}");
let back = NetworkConfig::from_json_str(&json).unwrap();
assert_eq!(back.addresses()[0].address().to_string(), "192.168.1.5");
assert_eq!(back.addresses()[0].prefix_len(), 24);
}
#[test]
fn address_rejects_out_of_range_prefix() {
let err = NetworkConfig::from_json_str(
r#"{"addresses":[{"dev":"eth0","address":"10.0.0.1/99"}]}"#,
)
.unwrap_err();
assert!(err.to_string().contains("prefix"), "{err}");
}
#[test]
fn route_default_keyword_roundtrips() {
let cfg = NetworkConfig::new()
.route("0.0.0.0/0", |r| r.via("192.0.2.1"))
.unwrap();
let json = cfg.to_json_string().unwrap();
assert!(json.contains(r#""destination":"default""#), "{json}");
let back = NetworkConfig::from_json_str(&json).unwrap();
assert!(back.routes()[0].destination().is_unspecified());
assert_eq!(back.routes()[0].prefix_len(), 0);
}
#[test]
fn route_default_infers_v6_from_gateway() {
let back = NetworkConfig::from_json_str(
r#"{"routes":[{"destination":"default","gateway":"2001:db8::1"}]}"#,
)
.unwrap();
assert!(back.routes()[0].destination().is_ipv6());
}
#[test]
fn route_rejects_bad_gateway() {
let err = NetworkConfig::from_json_str(
r#"{"routes":[{"destination":"10.0.0.0/8","gateway":"not-an-ip"}]}"#,
)
.unwrap_err();
assert!(err.to_string().contains("gateway"), "{err}");
}
#[test]
fn mac_roundtrips_as_colon_string() {
let cfg = NetworkConfig::new()
.link("dummy0", |l| l.dummy().address([0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff]));
let json = cfg.to_json_string().unwrap();
assert!(json.contains(r#""address":"aa:bb:cc:dd:ee:ff""#), "{json}");
assert_roundtrips(&cfg);
}
#[test]
fn mac_rejects_malformed_string() {
let err = NetworkConfig::from_json_str(
r#"{"links":[{"name":"d0","link-type":"dummy","address":"aa:bb:cc"}]}"#,
)
.unwrap_err();
assert!(err.to_string().contains("MAC"), "{err}");
}
#[test]
fn empty_config_omits_empty_sections() {
let json = NetworkConfig::new().to_json_string().unwrap();
assert_eq!(json, "{}");
let back = NetworkConfig::from_json_str("{}").unwrap();
assert!(back.links().is_empty() && back.addresses().is_empty());
}
#[test]
fn documented_json_example_parses() {
let cfg = NetworkConfig::from_json_str(
r#"{
"links": [
{ "name": "br0", "link-type": "bridge", "state": "up" },
{ "name": "veth0", "link-type": { "veth": { "peer": "veth1" } } }
],
"addresses": [ { "dev": "br0", "address": "10.0.0.1/24" } ],
"routes": [ { "destination": "10.1.0.0/16", "gateway": "10.0.0.254", "dev": "br0" } ]
}"#,
)
.expect("documented JSON must parse");
assert_eq!(cfg.links().len(), 2);
assert_eq!(cfg.addresses().len(), 1);
assert_eq!(cfg.routes().len(), 1);
assert_eq!(cfg.links()[1].name(), "veth0");
}
#[test]
fn unit_link_type_keeps_bare_string_shape() {
let cfg = NetworkConfig::new().link("d0", |l| l.dummy());
let json = cfg.to_json_string().unwrap();
assert!(json.contains(r#""link-type":"dummy""#), "{json}");
}
}
#[cfg(feature = "serde")]
mod mac_serde {
use serde::{Deserialize, Deserializer, Serializer};
pub(super) fn serialize<S>(mac: &Option<[u8; 6]>, s: S) -> Result<S::Ok, S::Error>
where
S: Serializer,
{
match mac {
Some(m) => s.serialize_some(&format!(
"{:02x}:{:02x}:{:02x}:{:02x}:{:02x}:{:02x}",
m[0], m[1], m[2], m[3], m[4], m[5]
)),
None => s.serialize_none(),
}
}
pub(super) fn deserialize<'de, D>(d: D) -> Result<Option<[u8; 6]>, D::Error>
where
D: Deserializer<'de>,
{
match Option::<String>::deserialize(d)? {
None => Ok(None),
Some(s) => parse_mac(&s).map(Some).map_err(serde::de::Error::custom),
}
}
fn parse_mac(s: &str) -> Result<[u8; 6], String> {
let mut out = [0u8; 6];
let mut n = 0;
for (i, part) in s.split(':').enumerate() {
if i >= 6 {
return Err(format!(
"invalid MAC `{s}`: expected 6 colon-separated octets"
));
}
out[i] = u8::from_str_radix(part, 16)
.map_err(|_| format!("invalid MAC `{s}`: octet `{part}` is not hex"))?;
n = i + 1;
}
if n != 6 {
return Err(format!(
"invalid MAC `{s}`: expected 6 colon-separated octets, got {n}"
));
}
Ok(out)
}
}
#[cfg(all(test, feature = "schemars"))]
mod schemars_tests {
use super::*;
fn schema_value() -> serde_json::Value {
serde_json::from_str(&NetworkConfig::json_schema()).expect("schema is valid JSON")
}
fn defs(schema: &serde_json::Value) -> &serde_json::Map<String, serde_json::Value> {
schema
.get("definitions")
.or_else(|| schema.get("$defs"))
.and_then(|d| d.as_object())
.expect("schema exposes a definitions/$defs map")
}
#[test]
fn schema_declares_draft_07() {
let s = schema_value();
assert_eq!(
s.get("$schema").and_then(|v| v.as_str()),
Some("http://json-schema.org/draft-07/schema#"),
"json_schema must emit draft-07, as its docs and CLAUDE.md state"
);
assert!(
s.get("definitions").is_some(),
"draft-07 output should carry `definitions`"
);
}
#[test]
fn schema_generates_and_titles_the_config_root() {
let s = schema_value();
assert_eq!(
s.get("title").and_then(|t| t.as_str()),
Some("NetworkConfig"),
"root schema title should be the type name"
);
let d = defs(&s);
for t in ["DeclaredLink", "DeclaredLinkType", "DeclaredQdiscType"] {
assert!(
d.contains_key(t),
"schema missing definition for {t}; has {:?}",
d.keys().collect::<Vec<_>>()
);
}
assert!(d.contains_key("AddressRepr"), "schema must route addresses through AddressRepr");
assert!(d.contains_key("RouteRepr"), "schema must route routes through RouteRepr");
assert!(!d.contains_key("DeclaredAddress"), "DeclaredAddress parsed fields must not appear");
assert!(!d.contains_key("DeclaredRoute"), "DeclaredRoute parsed fields must not appear");
}
#[test]
fn address_and_route_describe_cidr_strings_not_parsed_fields() {
let s = schema_value();
let d = defs(&s);
assert_eq!(
d["AddressRepr"].pointer("/properties/address/type").and_then(|v| v.as_str()),
Some("string"),
"AddressRepr.address must be a (CIDR) string"
);
assert_eq!(
d["RouteRepr"].pointer("/properties/destination/type").and_then(|v| v.as_str()),
Some("string"),
"RouteRepr.destination must be a (CIDR/default) string"
);
}
#[test]
fn mac_field_is_a_string_not_a_byte_array() {
let s = schema_value();
let d = defs(&s);
let mac = d["DeclaredLink"]
.pointer("/properties/address")
.expect("DeclaredLink.address present in schema");
let rendered = serde_json::to_string(mac).unwrap();
assert!(rendered.contains("string"), "MAC must be string-typed: {rendered}");
assert!(
!rendered.contains("integer") && !rendered.contains("\"array\""),
"MAC must NOT be modelled as a byte array: {rendered}"
);
}
#[test]
fn schema_value_helper_matches_string_helper() {
let from_value = serde_json::to_value(NetworkConfig::json_schema_value()).unwrap();
let from_string = schema_value();
assert_eq!(from_value, from_string, "the two json_schema accessors must agree");
}
}