use std::net::IpAddr;
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 = schemars::schema_for!(NetworkConfig);
serde_json::to_string_pretty(&schema)
.expect("RootSchema serialization is infallible")
}
#[cfg(feature = "schemars")]
pub fn json_schema_value() -> schemars::schema::RootSchema {
schemars::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() -> String {
AddressRepr::schema_name()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
AddressRepr::schema_id()
}
fn json_schema(g: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
AddressRepr::json_schema(g)
}
fn is_referenceable() -> bool {
AddressRepr::is_referenceable()
}
}
#[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() -> String {
RouteRepr::schema_name()
}
fn schema_id() -> std::borrow::Cow<'static, str> {
RouteRepr::schema_id()
}
fn json_schema(g: &mut schemars::r#gen::SchemaGenerator) -> schemars::schema::Schema {
RouteRepr::json_schema(g)
}
fn is_referenceable() -> bool {
RouteRepr::is_referenceable()
}
}
#[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 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,
}
#[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 {
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>,
},
Htb { default_class: u32 },
FqCodel {
limit: Option<u32>,
target_us: Option<u32>,
interval_us: Option<u32>,
},
Tbf {
rate_bps: u64,
burst_bytes: u32,
limit_bytes: Option<u32>,
},
Sfq { perturb_secs: 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,
});
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 * 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 jitter_ms(mut self, ms: u32) -> Self {
if let Some(DeclaredQdiscType::Netem { jitter_us, .. }) = &mut self.qdisc_type {
*jitter_us = Some(ms * 1000);
}
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 limit(mut self, packets: u32) -> Self {
if let Some(DeclaredQdiscType::Netem { limit, .. }) = &mut self.qdisc_type {
*limit = Some(packets);
}
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,
});
self
}
pub fn tbf(mut self, rate_bps: u64, burst_bytes: u32) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::Tbf {
rate_bps,
burst_bytes,
limit_bytes: None,
});
self
}
pub fn sfq(mut self) -> Self {
self.qdisc_type = Some(DeclaredQdiscType::Sfq { perturb_secs: 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.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,
}),
}
}
}
#[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));
}
}
#[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 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_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");
}
}