[PATCH proxmox-ve-rs v2 1/7] sdn: fabric: add BGP protocol support

Hannes Laimer
2026-04-15T11:11:59Z

From: Stefan Hanreich <s.hanreich@proxmox.com>

Add BGP as a fabric protocol for eBGP unnumbered underlays. Each node
has a mandatory, globally unique ASN for interface-based eBGP peering.

Unlike OSPF and OpenFabric, BGP does not have its own FRR daemon -
the fabric config needs to coexist with EVPN in a single 'router bgp'
block. To handle this, the fabric merges into an existing router
rather than replacing it, using local-as to present the per-node ASN
to underlay peers when the router already runs under the EVPN ASN.

For IPv6-only nodes, the BGP router-id is derived from the IPv6
address using FNV-1a, since router-id must be a 32-bit value.

Co-authored-by: Hannes Laimer <h.laimer@proxmox.com>
Signed-off-by: Stefan Hanreich <s.hanreich@proxmox.com>
Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
proxmox-frr/src/ser/bgp.rs | 85 +++++-
proxmox-ve-config/src/sdn/fabric/frr.rs | 254 +++++++++++++++-
proxmox-ve-config/src/sdn/fabric/mod.rs | 111 +++++++
.../src/sdn/fabric/section_config/fabric.rs | 22 ++
.../src/sdn/fabric/section_config/mod.rs | 19 ++
.../src/sdn/fabric/section_config/node.rs | 21 ++
.../sdn/fabric/section_config/protocol/bgp.rs | 286 ++++++++++++++++++
.../sdn/fabric/section_config/protocol/mod.rs | 1 +
.../tests/fabric/cfg/bgp_default/fabrics.cfg | 17 ++
.../fabric/cfg/bgp_ipv6_only/fabrics.cfg | 17 ++
proxmox-ve-config/tests/fabric/main.rs | 119 +++++++-
.../snapshots/fabric__bgp_default_pve.snap | 28 ++
.../snapshots/fabric__bgp_default_pve1.snap | 27 ++
.../snapshots/fabric__bgp_ipv6_only_pve.snap | 29 ++
.../snapshots/fabric__bgp_ipv6_only_pve1.snap | 28 ++
.../fabric__bgp_merge_with_evpn_pve.snap | 34 +++
16 files changed, 1089 insertions(+), 9 deletions(-)
create mode 100644 proxmox-ve-config/src/sdn/fabric/section_config/protocol/bgp.rs
create mode 100644 proxmox-ve-config/tests/fabric/cfg/bgp_default/fabrics.cfg
create mode 100644 proxmox-ve-config/tests/fabric/cfg/bgp_ipv6_only/fabrics.cfg
create mode 100644 proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve.snap
create mode 100644 proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve1.snap
create mode 100644 proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve.snap
create mode 100644 proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve1.snap
create mode 100644 proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_merge_with_evpn_pve.snap

diff --git a/proxmox-frr/src/ser/bgp.rs b/proxmox-frr/src/ser/bgp.rs
index 0bf4a1d..5d454fa 100644
--- a/proxmox-frr/src/ser/bgp.rs
+++ b/proxmox-frr/src/ser/bgp.rs
@@ -43,9 +43,9 @@ pub enum LocalAsFlags {
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub struct LocalAsSettings {
- asn: u32,
+ pub asn: u32,
#[serde(default)]
- mode: Option<LocalAsFlags>,
+ pub mode: Option<LocalAsFlags>,
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
@@ -159,9 +159,84 @@ pub struct CommonAddressFamilyOptions {

#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize, Default)]
pub struct AddressFamilies {
- ipv4_unicast: Option<Ipv4UnicastAF>,
- ipv6_unicast: Option<Ipv6UnicastAF>,
- l2vpn_evpn: Option<L2vpnEvpnAF>,
+ pub ipv4_unicast: Option<Ipv4UnicastAF>,
+ pub ipv6_unicast: Option<Ipv6UnicastAF>,
+ pub l2vpn_evpn: Option<L2vpnEvpnAF>,
+}
+
+impl AddressFamilies {
+ /// Merge another [`AddressFamilies`] into this one.
+ ///
+ /// For each address family: if `self` already has it, extend its neighbors, networks, and
+ /// redistribute lists. If `self` doesn't have it, take it from `other`.
+ pub fn merge(&mut self, other: AddressFamilies) {
+ match (self.ipv4_unicast.as_mut(), other.ipv4_unicast) {
+ (Some(existing), Some(incoming)) => {
+ existing
+ .common_options
+ .neighbors
+ .extend(incoming.common_options.neighbors);
+ existing
+ .common_options
+ .import_vrf
+ .extend(incoming.common_options.import_vrf);
+ existing
+ .common_options
+ .custom_frr_config
+ .extend(incoming.common_options.custom_frr_config);
+ existing.networks.extend(incoming.networks);
+ existing.redistribute.extend(incoming.redistribute);
+ }
+ (None, Some(incoming)) => {
+ self.ipv4_unicast = Some(incoming);
+ }
+ _ => {}
+ }
+
+ match (self.ipv6_unicast.as_mut(), other.ipv6_unicast) {
+ (Some(existing), Some(incoming)) => {
+ existing
+ .common_options
+ .neighbors
+ .extend(incoming.common_options.neighbors);
+ existing
+ .common_options
+ .import_vrf
+ .extend(incoming.common_options.import_vrf);
+ existing
+ .common_options
+ .custom_frr_config
+ .extend(incoming.common_options.custom_frr_config);
+ existing.networks.extend(incoming.networks);
+ existing.redistribute.extend(incoming.redistribute);
+ }
+ (None, Some(incoming)) => {
+ self.ipv6_unicast = Some(incoming);
+ }
+ _ => {}
+ }
+
+ // l2vpn_evpn: only take from other if self doesn't have it (fabric never sets this)
+ if self.l2vpn_evpn.is_none() {
+ self.l2vpn_evpn = other.l2vpn_evpn;
+ }
+ }
+}
+
+impl BgpRouter {
+ /// Merge a fabric-generated [`BgpRouter`] into an existing one.
+ ///
+ /// Appends the fabric's neighbor groups and merges address families. Keeps the existing
+ /// router's ASN, router-id, and other top-level settings. The caller is responsible for
+ /// setting `local_as` on the fabric's neighbor group if the ASNs differ.
+ pub fn merge_fabric(&mut self, other: BgpRouter) {
+ self.neighbor_groups.extend(other.neighbor_groups);
+ self.address_families.merge(other.address_families);
+
+ if self.default_ipv4_unicast.is_none() {
+ self.default_ipv4_unicast = other.default_ipv4_unicast;
+ }
+ }
}

#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
diff --git a/proxmox-ve-config/src/sdn/fabric/frr.rs b/proxmox-ve-config/src/sdn/fabric/frr.rs
index b816ef6..373df80 100644
--- a/proxmox-ve-config/src/sdn/fabric/frr.rs
+++ b/proxmox-ve-config/src/sdn/fabric/frr.rs
@@ -2,19 +2,28 @@ use std::net::{IpAddr, Ipv4Addr};

use tracing;

+use proxmox_frr::ser::bgp::{
+ AddressFamilies, AddressFamilyNeighbor, BgpRouter, CommonAddressFamilyOptions, Ipv4UnicastAF,
+ Ipv6UnicastAF, LocalAsFlags, LocalAsSettings, NeighborGroup, NeighborRemoteAs,
+ RedistributeProtocol, Redistribution,
+};
use proxmox_frr::ser::openfabric::{OpenfabricInterface, OpenfabricRouter, OpenfabricRouterName};
use proxmox_frr::ser::ospf::{self, OspfInterface, OspfRouter};
use proxmox_frr::ser::route_map::{
- AccessAction, AccessListName, RouteMapEntry, RouteMapMatch, RouteMapName, RouteMapSet,
+ AccessListName, RouteMapEntry, RouteMapMatch, RouteMapName, RouteMapSet,
};
+use proxmox_frr::ser::AccessAction;
use proxmox_frr::ser::{
- self, FrrConfig, FrrProtocol, FrrWord, Interface, InterfaceName, IpProtocolRouteMap,
+ self, FrrConfig, FrrProtocol, FrrWord, Interface, InterfaceName, IpProtocolRouteMap, VrfName,
};
-use proxmox_network_types::ip_address::Cidr;
+use proxmox_network_types::ip_address::{Cidr, Ipv4Cidr, Ipv6Cidr};
use proxmox_sdn_types::net::Net;

use crate::common::valid::Valid;
+
+use crate::sdn::fabric::section_config::protocol::bgp::{router_id_from_ipv6, BgpNode};
use crate::sdn::fabric::section_config::protocol::{
+ bgp::BgpRedistributionSource,
openfabric::{OpenfabricInterfaceProperties, OpenfabricProperties},
ospf::OspfInterfaceProperties,
};
@@ -277,6 +286,214 @@ pub fn build_fabric(
);
}
}
+ FabricEntry::Bgp(bgp_entry) => {
+ let Ok(node) = bgp_entry.node_section(&current_node) else {
+ continue;
+ };
+
+ let BgpNode::Internal(properties) = node.properties() else {
+ continue;
+ };
+
+ let fabric = bgp_entry.fabric_section();
+
+ let local_asn = properties.asn().as_u32();
+
+ let mut bgp_interfaces = Vec::new();
+ for interface in properties.interfaces() {
+ bgp_interfaces.push(interface.name.as_str().try_into()?)
+ }
+
+ let neighbor_group = NeighborGroup {
+ name: FrrWord::new(fabric.id().to_string())?,
+ bfd: fabric.properties().bfd(),
+ remote_as: NeighborRemoteAs::External,
+ local_as: Default::default(),
+ interfaces: bgp_interfaces,
+ ips: Default::default(),
+ ebgp_multihop: Default::default(),
+ update_source: Default::default(),
+ };
+
+ let redistribute: Vec<Redistribution> = fabric
+ .properties()
+ .redistribute
+ .iter()
+ .map(|redistribution| Redistribution {
+ protocol: match redistribution.source {
+ BgpRedistributionSource::Ospf => RedistributeProtocol::Ospf,
+ BgpRedistributionSource::Connected => RedistributeProtocol::Connected,
+ BgpRedistributionSource::Isis => RedistributeProtocol::Isis,
+ BgpRedistributionSource::Kernel => RedistributeProtocol::Kernel,
+ BgpRedistributionSource::Openfabric => RedistributeProtocol::Openfabric,
+ BgpRedistributionSource::Ospf6 => RedistributeProtocol::Ospf6,
+ BgpRedistributionSource::Static => RedistributeProtocol::Static,
+ },
+ metric: redistribution.metric,
+ route_map: redistribution.route_map.clone().map(RouteMapName::new),
+ })
+ .collect();
+
+ let af_neighbors = vec![AddressFamilyNeighbor {
+ name: fabric.id().to_string(),
+ route_map_in: None,
+ route_map_out: None,
+ soft_reconfiguration_inbound: Some(true),
+ }];
+
+ let ipv4_family = node.ip().map(|ipv4| Ipv4UnicastAF {
+ common_options: CommonAddressFamilyOptions {
+ import_vrf: Default::default(),
+ neighbors: af_neighbors.clone(),
+ custom_frr_config: Default::default(),
+ },
+ redistribute: redistribute.clone(),
+ networks: vec![Ipv4Cidr::from(ipv4)],
+ });
+
+ let ipv6_family = node.ip6().map(|ipv6| Ipv6UnicastAF {
+ common_options: CommonAddressFamilyOptions {
+ import_vrf: Default::default(),
+ neighbors: af_neighbors,
+ custom_frr_config: Default::default(),
+ },
+ networks: vec![Ipv6Cidr::from(ipv6)],
+ redistribute,
+ });
+
+ let address_families = AddressFamilies {
+ ipv4_unicast: ipv4_family,
+ ipv6_unicast: ipv6_family,
+ ..Default::default()
+ };
+
+ let router_id = match (node.ip(), node.ip6()) {
+ (Some(ipv4), _) => ipv4,
+ (None, Some(ipv6)) => router_id_from_ipv6(&ipv6),
+ (None, None) => {
+ anyhow::bail!("BGP node must have an IPv4 or IPv6 address")
+ }
+ };
+
+ let mut router = BgpRouter {
+ asn: local_asn,
+ router_id,
+ neighbor_groups: vec![neighbor_group],
+ address_families,
+ coalesce_time: Default::default(),
+ default_ipv4_unicast: Some(false),
+ hard_administrative_reset: Default::default(),
+ graceful_restart_notification: Default::default(),
+ disable_ebgp_connected_route_check: Default::default(),
+ bestpath_as_path_multipath_relax: Default::default(),
+ custom_frr_config: Default::default(),
+ };
+
+ if let Some(existing) = frr_config.bgp.vrf_router.get_mut(&VrfName::Default) {
+ // If the existing router uses a different ASN (e.g. the
+ // EVPN ASN), set local-as on the fabric neighbor group so
+ // the underlay peers see the correct per-node ASN.
+ if existing.asn != local_asn {
+ if let Some(ng) = router.neighbor_groups.first_mut() {
+ ng.local_as = Some(LocalAsSettings {
+ asn: local_asn,
+ mode: Some(LocalAsFlags::ReplaceAs),
+ });
+ }
+ }
+ existing.merge_fabric(router);
+ } else {
+ frr_config.bgp.vrf_router.insert(VrfName::Default, router);
+ }
+
+ // Create access-list and route-map for source address selection,
+ // so the kernel uses the loopback IP as source for fabric-learned routes.
+ if let Some(ipv4_prefix) = fabric.ip_prefix() {
+ let access_list_name =
+ AccessListName::new(format!("pve_bgp_{}_ips", fabric_id));
+
+ let rule = ser::route_map::AccessListRule {
+ action: AccessAction::Permit,
+ network: Cidr::from(ipv4_prefix),
+ is_ipv6: false,
+ seq: None,
+ };
+
+ frr_config.access_lists.insert(access_list_name, vec![rule]);
+
+ let (routemap_name, routemap_rule) = build_bgp_routemap(
+ fabric_id,
+ IpAddr::from(node.ip().expect("node must have an ipv4 address")),
+ routemap_seq,
+ );
+
+ routemap_seq += 10;
+
+ if let Some(routemap) = frr_config.routemaps.get_mut(&routemap_name) {
+ routemap.push(routemap_rule)
+ } else {
+ frr_config
+ .routemaps
+ .insert(routemap_name.clone(), vec![routemap_rule]);
+ }
+
+ if let Some(routemap) = frr_config.protocol_routemaps.get_mut(&FrrProtocol::Bgp)
+ {
+ routemap.v4 = Some(routemap_name);
+ } else {
+ frr_config.protocol_routemaps.insert(
+ FrrProtocol::Bgp,
+ IpProtocolRouteMap {
+ v4: Some(routemap_name),
+ v6: None,
+ },
+ );
+ }
+ }
+
+ if let Some(ipv6_prefix) = fabric.ip6_prefix() {
+ let access_list_name =
+ AccessListName::new(format!("pve_bgp_{}_ip6s", fabric_id));
+
+ let rule = ser::route_map::AccessListRule {
+ action: AccessAction::Permit,
+ network: Cidr::from(ipv6_prefix),
+ is_ipv6: true,
+ seq: None,
+ };
+
+ frr_config.access_lists.insert(access_list_name, vec![rule]);
+
+ let (routemap_name, routemap_rule) = build_bgp_routemap(
+ fabric_id,
+ IpAddr::from(node.ip6().expect("node must have an ipv6 address")),
+ routemap_seq,
+ );
+
+ routemap_seq += 10;
+
+ if let Some(routemap) = frr_config.routemaps.get_mut(&routemap_name) {
+ routemap.push(routemap_rule)
+ } else {
+ frr_config
+ .routemaps
+ .insert(routemap_name.clone(), vec![routemap_rule]);
+ }
+
+ if let Some(routemap) = frr_config.protocol_routemaps.get_mut(&FrrProtocol::Bgp)
+ {
+ routemap.v6 = Some(routemap_name);
+ } else {
+ frr_config.protocol_routemaps.insert(
+ FrrProtocol::Bgp,
+ IpProtocolRouteMap {
+ v4: None,
+ v6: Some(routemap_name),
+ },
+ );
+ }
+ }
+ }
}
}

@@ -416,6 +633,37 @@ fn build_openfabric_routemap(
)
}

+/// Helper that builds a RouteMap for the BGP protocol.
+fn build_bgp_routemap(
+ fabric_id: &FabricId,
+ router_ip: IpAddr,
+ seq: u16,
+) -> (RouteMapName, RouteMapEntry) {
+ let routemap_name = match router_ip {
+ IpAddr::V4(_) => RouteMapName::new("pve_bgp".to_owned()),
+ IpAddr::V6(_) => RouteMapName::new("pve_bgp6".to_owned()),
+ };
+ (
+ routemap_name,
+ RouteMapEntry {
+ seq,
+ action: AccessAction::Permit,
+ matches: vec![match router_ip {
+ IpAddr::V4(_) => RouteMapMatch::IpAddressAccessList(AccessListName::new(format!(
+ "pve_bgp_{fabric_id}_ips"
+ ))),
+ IpAddr::V6(_) => RouteMapMatch::Ip6AddressAccessList(AccessListName::new(format!(
+ "pve_bgp_{fabric_id}_ip6s"
+ ))),
+ }],
+ sets: vec![RouteMapSet::Src(router_ip)],
+ custom_frr_config: Vec::new(),
+ call: None,
+ exit_action: None,
+ },
+ )
+}
+
/// Helper that builds a RouteMap for the OSPF protocol.
fn build_ospf_dummy_routemap(
fabric_id: &FabricId,
diff --git a/proxmox-ve-config/src/sdn/fabric/mod.rs b/proxmox-ve-config/src/sdn/fabric/mod.rs
index 677a309..f4c134b 100644
--- a/proxmox-ve-config/src/sdn/fabric/mod.rs
+++ b/proxmox-ve-config/src/sdn/fabric/mod.rs
@@ -20,6 +20,10 @@ use crate::sdn::fabric::section_config::node::{
api::{NodeDataUpdater, NodeDeletableProperties, NodeUpdater},
Node, NodeId, NodeSection,
};
+use crate::sdn::fabric::section_config::protocol::bgp::{
+ router_id_from_ipv6, BgpNode, BgpNodeDeletableProperties, BgpNodePropertiesUpdater,
+ BgpProperties,
+};
use crate::sdn::fabric::section_config::protocol::openfabric::{
OpenfabricDeletableProperties, OpenfabricNodeDeletableProperties, OpenfabricNodeProperties,
OpenfabricNodePropertiesUpdater, OpenfabricProperties, OpenfabricPropertiesUpdater,
@@ -64,6 +68,10 @@ pub enum FabricConfigError {
// this is technically possible, but we don't allow it
#[error("duplicate OSPF area")]
DuplicateOspfArea,
+ #[error("ASN {0} is already used by another BGP fabric node")]
+ DuplicateBgpAsn(u32),
+ #[error("BGP router-id collision: nodes have different IPv6 addresses but the same derived router-id {0}")]
+ DuplicateBgpRouterId(std::net::Ipv4Addr),
#[error("IP prefix {0} in fabric '{1}' overlaps with IPv4 prefix {2} in fabric '{3}'")]
OverlappingIp4Prefix(String, String, String, String),
#[error("IPv6 prefix {0} in fabric '{1}' overlaps with IPv6 prefix {2} in fabric '{3}'")]
@@ -162,6 +170,33 @@ where
}
}

+impl Entry<BgpProperties, BgpNode> {
+ /// Get the BGP fabric config.
+ ///
+ /// This method is implemented for [`Entry<BgpProperties, BgpNode>`],
+ /// so it is guaranteed that a [`FabricSection<BgpProperties>`] is returned.
+ pub fn fabric_section(&self) -> &FabricSection<BgpProperties> {
+ if let Fabric::Bgp(section) = &self.fabric {
+ return section;
+ }
+
+ unreachable!();
+ }
+
+ /// Get the BGP node config for the given node_id.
+ ///
+ /// This method is implemented for [`Entry<BgpProperties, BgpNode>`],
+ /// so it is guaranteed that a [`NodeSection<BgpNode>`] is returned.
+ /// An error is returned if the node is not found.
+ pub fn node_section(&self, id: &NodeId) -> Result<&NodeSection<BgpNode>, FabricConfigError> {
+ if let Node::Bgp(section) = self.get_node(id)? {
+ return Ok(section);
+ }
+
+ unreachable!();
+ }
+}
+
impl Entry<OpenfabricProperties, OpenfabricNodeProperties> {
/// Get the OpenFabric fabric config.
///
@@ -230,6 +265,7 @@ impl Entry<OspfProperties, OspfNodeProperties> {
pub enum FabricEntry {
Openfabric(Entry<OpenfabricProperties, OpenfabricNodeProperties>),
Ospf(Entry<OspfProperties, OspfNodeProperties>),
+ Bgp(Entry<BgpProperties, BgpNode>),
}

impl FabricEntry {
@@ -241,6 +277,7 @@ impl FabricEntry {
entry.add_node(node_section)
}
(FabricEntry::Ospf(entry), Node::Ospf(node_section)) => entry.add_node(node_section),
+ (FabricEntry::Bgp(entry), Node::Bgp(node_section)) => entry.add_node(node_section),
_ => Err(FabricConfigError::ProtocolMismatch),
}
}
@@ -251,6 +288,7 @@ impl FabricEntry {
match self {
FabricEntry::Openfabric(entry) => entry.get_node(id),
FabricEntry::Ospf(entry) => entry.get_node(id),
+ FabricEntry::Bgp(entry) => entry.get_node(id),
}
}

@@ -260,6 +298,7 @@ impl FabricEntry {
match self {
FabricEntry::Openfabric(entry) => entry.get_node_mut(id),
FabricEntry::Ospf(entry) => entry.get_node_mut(id),
+ FabricEntry::Bgp(entry) => entry.get_node_mut(id),
}
}

@@ -339,6 +378,46 @@ impl FabricEntry {

Ok(())
}
+ (Node::Bgp(node_section), NodeUpdater::Bgp(updater)) => {
+ let BgpNode::Internal(ref mut props) = node_section.properties else {
+ return Err(FabricConfigError::ProtocolMismatch);
+ };
+
+ let NodeDataUpdater::<BgpNodePropertiesUpdater, BgpNodeDeletableProperties> {
+ ip,
+ ip6,
+ properties: BgpNodePropertiesUpdater { asn, interfaces },
+ delete,
+ } = updater;
+
+ if let Some(ip) = ip {
+ node_section.ip = Some(ip);
+ }
+
+ if let Some(ip) = ip6 {
+ node_section.ip6 = Some(ip);
+ }
+
+ if let Some(asn) = asn {
+ props.asn = asn;
+ }
+
+ if let Some(interfaces) = interfaces {
+ props.interfaces = interfaces;
+ }
+
+ for property in delete {
+ match property {
+ NodeDeletableProperties::Ip => node_section.ip = None,
+ NodeDeletableProperties::Ip6 => node_section.ip6 = None,
+ NodeDeletableProperties::Protocol(
+ BgpNodeDeletableProperties::Interfaces,
+ ) => props.interfaces = Vec::new(),
+ }
+ }
+
+ Ok(())
+ }
_ => Err(FabricConfigError::ProtocolMismatch),
}
}
@@ -348,6 +427,7 @@ impl FabricEntry {
match self {
FabricEntry::Openfabric(entry) => entry.nodes.iter(),
FabricEntry::Ospf(entry) => entry.nodes.iter(),
+ FabricEntry::Bgp(entry) => entry.nodes.iter(),
}
}

@@ -356,6 +436,7 @@ impl FabricEntry {
match self {
FabricEntry::Openfabric(entry) => entry.delete_node(id),
FabricEntry::Ospf(entry) => entry.delete_node(id),
+ FabricEntry::Bgp(entry) => entry.delete_node(id),
}
}

@@ -365,6 +446,7 @@ impl FabricEntry {
match self {
FabricEntry::Openfabric(entry) => entry.into_pair(),
FabricEntry::Ospf(entry) => entry.into_pair(),
+ FabricEntry::Bgp(entry) => entry.into_pair(),
}
}

@@ -373,6 +455,7 @@ impl FabricEntry {
match self {
FabricEntry::Openfabric(entry) => &entry.fabric,
FabricEntry::Ospf(entry) => &entry.fabric,
+ FabricEntry::Bgp(entry) => &entry.fabric,
}
}

@@ -381,6 +464,7 @@ impl FabricEntry {
match self {
FabricEntry::Openfabric(entry) => &mut entry.fabric,
FabricEntry::Ospf(entry) => &mut entry.fabric,
+ FabricEntry::Bgp(entry) => &mut entry.fabric,
}
}
}
@@ -392,6 +476,7 @@ impl From<Fabric> for FabricEntry {
FabricEntry::Openfabric(Entry::new(fabric_section))
}
Fabric::Ospf(fabric_section) => FabricEntry::Ospf(Entry::new(fabric_section)),
+ Fabric::Bgp(fabric_section) => FabricEntry::Bgp(Entry::new(fabric_section)),
}
}
}
@@ -513,6 +598,8 @@ impl Validatable for FabricConfig {
fn validate(&self) -> Result<(), FabricConfigError> {
let mut node_interfaces = HashSet::new();
let mut ospf_area = HashSet::new();
+ let mut bgp_asns = HashSet::new();
+ let mut bgp_router_ids = HashSet::new();

// Check for overlapping IP prefixes across fabrics
let fabrics: Vec<_> = self.fabrics.values().map(|f| f.fabric()).collect();
@@ -573,6 +660,30 @@ impl Validatable for FabricConfig {
return Err(FabricConfigError::DuplicateInterface);
}
}
+ Node::Bgp(node_section) => {
+ if let BgpNode::Internal(props) = node_section.properties() {
+ if !props.interfaces().all(|interface| {
+ node_interfaces.insert((node_id, interface.name().as_str()))
+ }) {
+ return Err(FabricConfigError::DuplicateInterface);
+ }
+
+ let asn = props.asn().as_u32();
+ if !bgp_asns.insert(asn) {
+ return Err(FabricConfigError::DuplicateBgpAsn(asn));
+ }
+
+ // IPv6-only nodes derive router-id via hash, check for collisions
+ if node_section.ip().is_none() {
+ if let Some(ipv6) = node_section.ip6() {
+ let rid = router_id_from_ipv6(&ipv6);
+ if !bgp_router_ids.insert(rid) {
+ return Err(FabricConfigError::DuplicateBgpRouterId(rid));
+ }
+ }
+ }
+ }
+ }
}
}

diff --git a/proxmox-ve-config/src/sdn/fabric/section_config/fabric.rs b/proxmox-ve-config/src/sdn/fabric/section_config/fabric.rs
index 38911a6..9f41eae 100644
--- a/proxmox-ve-config/src/sdn/fabric/section_config/fabric.rs
+++ b/proxmox-ve-config/src/sdn/fabric/section_config/fabric.rs
@@ -8,6 +8,9 @@ use proxmox_schema::{
};

use crate::common::valid::Validatable;
+use crate::sdn::fabric::section_config::protocol::bgp::{
+ BgpDeletableProperties, BgpProperties, BgpPropertiesUpdater,
+};
use crate::sdn::fabric::section_config::protocol::openfabric::{
OpenfabricDeletableProperties, OpenfabricProperties, OpenfabricPropertiesUpdater,
};
@@ -139,6 +142,10 @@ impl UpdaterType for FabricSection<OspfProperties> {
type Updater = FabricSectionUpdater<OspfPropertiesUpdater, OspfDeletableProperties>;
}

+impl UpdaterType for FabricSection<BgpProperties> {
+ type Updater = FabricSectionUpdater<BgpPropertiesUpdater, BgpDeletableProperties>;
+}
+
/// Enum containing all types of fabrics.
///
/// It utilizes [`FabricSection<T>`] to define all possible types of fabrics. For parsing the
@@ -159,6 +166,7 @@ impl UpdaterType for FabricSection<OspfProperties> {
pub enum Fabric {
Openfabric(FabricSection<OpenfabricProperties>),
Ospf(FabricSection<OspfProperties>),
+ Bgp(FabricSection<BgpProperties>),
}

impl UpdaterType for Fabric {
@@ -173,6 +181,7 @@ impl Fabric {
match self {
Self::Openfabric(fabric_section) => fabric_section.id(),
Self::Ospf(fabric_section) => fabric_section.id(),
+ Self::Bgp(fabric_section) => fabric_section.id(),
}
}

@@ -183,6 +192,7 @@ impl Fabric {
match self {
Fabric::Openfabric(fabric_section) => fabric_section.ip_prefix(),
Fabric::Ospf(fabric_section) => fabric_section.ip_prefix(),
+ Fabric::Bgp(fabric_section) => fabric_section.ip_prefix(),
}
}

@@ -193,6 +203,7 @@ impl Fabric {
match self {
Fabric::Openfabric(fabric_section) => fabric_section.ip_prefix = Some(ipv4_cidr),
Fabric::Ospf(fabric_section) => fabric_section.ip_prefix = Some(ipv4_cidr),
+ Fabric::Bgp(fabric_section) => fabric_section.ip_prefix = Some(ipv4_cidr),
}
}

@@ -203,6 +214,7 @@ impl Fabric {
match self {
Fabric::Openfabric(fabric_section) => fabric_section.ip6_prefix(),
Fabric::Ospf(fabric_section) => fabric_section.ip6_prefix(),
+ Fabric::Bgp(fabric_section) => fabric_section.ip6_prefix(),
}
}

@@ -213,6 +225,7 @@ impl Fabric {
match self {
Fabric::Openfabric(fabric_section) => fabric_section.ip6_prefix = Some(ipv6_cidr),
Fabric::Ospf(fabric_section) => fabric_section.ip6_prefix = Some(ipv6_cidr),
+ Fabric::Bgp(fabric_section) => fabric_section.ip6_prefix = Some(ipv6_cidr),
}
}
}
@@ -225,6 +238,7 @@ impl Validatable for Fabric {
match self {
Fabric::Openfabric(fabric_section) => fabric_section.validate(),
Fabric::Ospf(fabric_section) => fabric_section.validate(),
+ Fabric::Bgp(fabric_section) => fabric_section.validate(),
}
}
}
@@ -241,12 +255,19 @@ impl From<FabricSection<OspfProperties>> for Fabric {
}
}

+impl From<FabricSection<BgpProperties>> for Fabric {
+ fn from(section: FabricSection<BgpProperties>) -> Self {
+ Fabric::Bgp(section)
+ }
+}
+
/// Enum containing all updater types for fabrics
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "snake_case", tag = "protocol")]
pub enum FabricUpdater {
Openfabric(<FabricSection<OpenfabricProperties> as UpdaterType>::Updater),
Ospf(<FabricSection<OspfProperties> as UpdaterType>::Updater),
+ Bgp(<FabricSection<BgpProperties> as UpdaterType>::Updater),
}

impl Updater for FabricUpdater {
@@ -254,6 +275,7 @@ impl Updater for FabricUpdater {
match self {
FabricUpdater::Openfabric(updater) => updater.is_empty(),
FabricUpdater::Ospf(updater) => updater.is_empty(),
+ FabricUpdater::Bgp(updater) => updater.is_empty(),
}
}
}
diff --git a/proxmox-ve-config/src/sdn/fabric/section_config/mod.rs b/proxmox-ve-config/src/sdn/fabric/section_config/mod.rs
index d02d4ae..698dac9 100644
--- a/proxmox-ve-config/src/sdn/fabric/section_config/mod.rs
+++ b/proxmox-ve-config/src/sdn/fabric/section_config/mod.rs
@@ -10,6 +10,7 @@ use crate::sdn::fabric::section_config::{
fabric::{Fabric, FabricSection, FABRIC_ID_REGEX_STR},
node::{Node, NodeSection, NODE_ID_REGEX_STR},
protocol::{
+ bgp::{BgpNode, BgpProperties},
openfabric::{OpenfabricNodeProperties, OpenfabricProperties},
ospf::{OspfNodeProperties, OspfProperties},
},
@@ -31,8 +32,10 @@ impl From<Section> for FabricOrNode<Fabric, Node> {
match section {
Section::OpenfabricFabric(fabric_section) => Self::Fabric(fabric_section.into()),
Section::OspfFabric(fabric_section) => Self::Fabric(fabric_section.into()),
+ Section::BgpFabric(fabric_section) => Self::Fabric(fabric_section.into()),
Section::OpenfabricNode(node_section) => Self::Node(node_section.into()),
Section::OspfNode(node_section) => Self::Node(node_section.into()),
+ Section::BgpNode(node_section) => Self::Node(node_section.into()),
}
}
}
@@ -62,8 +65,10 @@ pub const SECTION_ID_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&SECTION
pub enum Section {
OpenfabricFabric(FabricSection<OpenfabricProperties>),
OspfFabric(FabricSection<OspfProperties>),
+ BgpFabric(FabricSection<BgpProperties>),
OpenfabricNode(NodeSection<OpenfabricNodeProperties>),
OspfNode(NodeSection<OspfNodeProperties>),
+ BgpNode(NodeSection<BgpNode>),
}

impl From<FabricSection<OpenfabricProperties>> for Section {
@@ -78,6 +83,12 @@ impl From<FabricSection<OspfProperties>> for Section {
}
}

+impl From<FabricSection<BgpProperties>> for Section {
+ fn from(section: FabricSection<BgpProperties>) -> Self {
+ Self::BgpFabric(section)
+ }
+}
+
impl From<NodeSection<OpenfabricNodeProperties>> for Section {
fn from(section: NodeSection<OpenfabricNodeProperties>) -> Self {
Self::OpenfabricNode(section)
@@ -90,11 +101,18 @@ impl From<NodeSection<OspfNodeProperties>> for Section {
}
}

+impl From<NodeSection<BgpNode>> for Section {
+ fn from(section: NodeSection<BgpNode>) -> Self {
+ Self::BgpNode(section)
+ }
+}
+
impl From<Fabric> for Section {
fn from(fabric: Fabric) -> Self {
match fabric {
Fabric::Openfabric(fabric_section) => fabric_section.into(),
Fabric::Ospf(fabric_section) => fabric_section.into(),
+ Fabric::Bgp(fabric_section) => fabric_section.into(),
}
}
}
@@ -104,6 +122,7 @@ impl From<Node> for Section {
match node {
Node::Openfabric(node_section) => node_section.into(),
Node::Ospf(node_section) => node_section.into(),
+ Node::Bgp(node_section) => node_section.into(),
}
}
}
diff --git a/proxmox-ve-config/src/sdn/fabric/section_config/node.rs b/proxmox-ve-config/src/sdn/fabric/section_config/node.rs
index 17d2f0b..8f4564c 100644
--- a/proxmox-ve-config/src/sdn/fabric/section_config/node.rs
+++ b/proxmox-ve-config/src/sdn/fabric/section_config/node.rs
@@ -10,6 +10,7 @@ use proxmox_schema::{
};

use crate::common::valid::Validatable;
+use crate::sdn::fabric::section_config::protocol::bgp::{BgpNode, BgpNodeProperties};
use crate::sdn::fabric::section_config::{
fabric::{FabricId, FABRIC_ID_REGEX_STR},
protocol::{openfabric::OpenfabricNodeProperties, ospf::OspfNodeProperties},
@@ -186,6 +187,7 @@ impl<T: ApiType> ApiType for NodeSection<T> {
pub enum Node {
Openfabric(NodeSection<OpenfabricNodeProperties>),
Ospf(NodeSection<OspfNodeProperties>),
+ Bgp(NodeSection<BgpNode>),
}

impl Node {
@@ -194,6 +196,7 @@ impl Node {
match self {
Node::Openfabric(node_section) => node_section.id(),
Node::Ospf(node_section) => node_section.id(),
+ Node::Bgp(node_section) => node_section.id(),
}
}

@@ -202,6 +205,7 @@ impl Node {
match self {
Node::Openfabric(node_section) => node_section.ip(),
Node::Ospf(node_section) => node_section.ip(),
+ Node::Bgp(node_section) => node_section.ip(),
}
}

@@ -210,6 +214,7 @@ impl Node {
match self {
Node::Openfabric(node_section) => node_section.ip6(),
Node::Ospf(node_section) => node_section.ip6(),
+ Node::Bgp(node_section) => node_section.ip6(),
}
}
}
@@ -221,6 +226,7 @@ impl Validatable for Node {
match self {
Node::Openfabric(node_section) => node_section.validate(),
Node::Ospf(node_section) => node_section.validate(),
+ Node::Bgp(node_section) => node_section.validate(),
}
}
}
@@ -237,6 +243,12 @@ impl From<NodeSection<OspfNodeProperties>> for Node {
}
}

+impl From<NodeSection<BgpNode>> for Node {
+ fn from(value: NodeSection<BgpNode>) -> Self {
+ Self::Bgp(value)
+ }
+}
+
/// API types for SDN fabric node configurations.
///
/// This module provides specialized types that are used for API interactions when retrieving,
@@ -258,6 +270,7 @@ pub mod api {
use proxmox_schema::{Updater, UpdaterType};

use crate::sdn::fabric::section_config::protocol::{
+ bgp::{BgpNodeDeletableProperties, BgpNodePropertiesUpdater},
openfabric::{
OpenfabricNodeDeletableProperties, OpenfabricNodeProperties,
OpenfabricNodePropertiesUpdater,
@@ -320,6 +333,7 @@ pub mod api {
pub enum Node {
Openfabric(NodeData<OpenfabricNodeProperties>),
Ospf(NodeData<OspfNodeProperties>),
+ Bgp(NodeData<BgpNode>),
}

impl From<super::Node> for Node {
@@ -327,6 +341,7 @@ pub mod api {
match value {
super::Node::Openfabric(node_section) => Self::Openfabric(node_section.into()),
super::Node::Ospf(node_section) => Self::Ospf(node_section.into()),
+ super::Node::Bgp(node_section) => Self::Bgp(node_section.into()),
}
}
}
@@ -336,6 +351,7 @@ pub mod api {
match value {
Node::Openfabric(node_section) => Self::Openfabric(node_section.into()),
Node::Ospf(node_section) => Self::Ospf(node_section.into()),
+ Node::Bgp(node_section) => Self::Bgp(node_section.into()),
}
}
}
@@ -349,6 +365,10 @@ pub mod api {
type Updater = NodeDataUpdater<OspfNodePropertiesUpdater, OspfNodeDeletableProperties>;
}

+ impl UpdaterType for NodeData<BgpNodeProperties> {
+ type Updater = NodeDataUpdater<BgpNodePropertiesUpdater, BgpNodeDeletableProperties>;
+ }
+
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct NodeDataUpdater<T, D> {
#[serde(skip_serializing_if = "Option::is_none")]
@@ -384,6 +404,7 @@ pub mod api {
NodeDataUpdater<OpenfabricNodePropertiesUpdater, OpenfabricNodeDeletableProperties>,
),
Ospf(NodeDataUpdater<OspfNodePropertiesUpdater, OspfNodeDeletableProperties>),
+ Bgp(NodeDataUpdater<BgpNodePropertiesUpdater, BgpNodeDeletableProperties>),
}

#[derive(Debug, Clone, Serialize, Deserialize)]
diff --git a/proxmox-ve-config/src/sdn/fabric/section_config/protocol/bgp.rs b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/bgp.rs
new file mode 100644
index 0000000..9b8e5fc
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/bgp.rs
@@ -0,0 +1,286 @@
+use std::net::{Ipv4Addr as StdIpv4Addr, Ipv6Addr};
+use std::ops::{Deref, DerefMut};
+
+use proxmox_network_types::ip_address::api_types::Ipv4Addr;
+use proxmox_schema::{ApiType, OneOfSchema, Schema, StringSchema, UpdaterType};
+use serde::{Deserialize, Serialize};
+
+use proxmox_schema::{api, property_string::PropertyString, ApiStringFormat, Updater};
+
+use crate::common::valid::Validatable;
+use crate::sdn::fabric::section_config::fabric::FabricSection;
+use crate::sdn::fabric::section_config::interface::InterfaceName;
+use crate::sdn::fabric::section_config::node::NodeSection;
+use crate::sdn::fabric::FabricConfigError;
+
+#[api]
+#[derive(Debug, Clone, Serialize, Deserialize, Updater, Hash)]
+#[serde(rename_all = "lowercase")]
+/// Redistribution Sources for BGP fabric
+pub enum BgpRedistributionSource {
+ /// redistribute connected routes
+ Connected,
+ /// redistribute IS-IS routes
+ Isis,
+ /// redistribute kernel routes
+ Kernel,
+ /// redistribute openfabric routes
+ Openfabric,
+ /// redistribute ospfv2 routes
+ Ospf,
+ /// redistribute ospfv3 routes
+ Ospf6,
+ /// redistribute static routes
+ Static,
+}
+
+#[api]
+#[derive(Debug, Clone, Serialize, Deserialize, Updater, Hash)]
+/// A BGP redistribution target
+pub struct BgpRedistribution {
+ /// The source used for redistribution
+ pub(crate) source: BgpRedistributionSource,
+ /// The metric to apply to redistributed routes
+ #[serde(skip_serializing_if = "Option::is_none")]
+ pub(crate) metric: Option<u32>,
+ /// Route MAP to use for filtering redistributed routes
+ #[serde(rename = "route-map", skip_serializing_if = "Option::is_none")]
+ pub(crate) route_map: Option<String>,
+}
+
+#[api(
+ type: Integer,
+ minimum: u32::MIN as i64,
+ maximum: u32::MAX as i64,
+)]
+#[derive(Debug, Clone, Serialize, Updater, Hash)]
+/// Autonomous system number as defined by RFC 6793
+pub struct ASN(u32);
+
+impl<'de> Deserialize<'de> for ASN {
+ fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
+ where
+ D: serde::Deserializer<'de>,
+ {
+ use serde::de::{self, Visitor};
+
+ struct AsnVisitor;
+
+ impl<'de> Visitor<'de> for AsnVisitor {
+ type Value = ASN;
+
+ fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
+ formatter.write_str("a u32 or string containing a u32")
+ }
+
+ fn visit_i64<E: de::Error>(self, v: i64) -> Result<ASN, E> {
+ u32::try_from(v)
+ .map(ASN)
+ .map_err(|_| E::custom(format!("ASN out of range: {v}")))
+ }
+
+ fn visit_u64<E: de::Error>(self, v: u64) -> Result<ASN, E> {
+ u32::try_from(v)
+ .map(ASN)
+ .map_err(|_| E::custom(format!("ASN out of range: {v}")))
+ }
+
+ fn visit_str<E: de::Error>(self, v: &str) -> Result<ASN, E> {
+ v.parse::<u32>()
+ .map(ASN)
+ .map_err(|_| E::custom(format!("invalid ASN: {v}")))
+ }
+ }
+
+ deserializer.deserialize_any(AsnVisitor)
+ }
+}
+
+impl UpdaterType for ASN {
+ type Updater = Option<ASN>;
+}
+
+impl ASN {
+ pub fn as_u32(&self) -> u32 {
+ self.0
+ }
+}
+
+#[api(
+ properties: {
+ redistribute: {
+ type: Array,
+ optional: true,
+ items: {
+ type: String,
+ description: "A BGP redistribution source",
+ format: &ApiStringFormat::PropertyString(&BgpRedistribution::API_SCHEMA),
+ }
+ }
+ },
+)]
+#[derive(Debug, Clone, Serialize, Deserialize, Updater, Hash)]
+/// Properties for an Bgp fabric.
+pub struct BgpProperties {
+ /// enable BFD for this fabric
+ #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+ pub(crate) bfd: bool,
+ /// redistribution configuration for this fabric
+ #[serde(default, skip_serializing_if = "Vec::is_empty")]
+ #[updater(serde(skip_serializing_if = "Option::is_none"))]
+ pub(crate) redistribute: Vec<PropertyString<BgpRedistribution>>,
+}
+
+impl BgpProperties {
+ pub fn bfd(&self) -> bool {
+ self.bfd
+ }
+}
+
+impl Validatable for FabricSection<BgpProperties> {
+ type Error = FabricConfigError;
+
+ /// Validate the [`FabricSection<BgpProperties>`].
+ fn validate(&self) -> Result<(), Self::Error> {
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case", untagged)]
+pub enum BgpDeletableProperties {}
+
+#[api]
+/// External Bgp Node
+#[derive(Debug, Clone, Serialize, Deserialize, Hash)]
+pub struct ExternalBgpNode {
+ peer_ip: Option<Ipv4Addr>,
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize, Hash)]
+#[serde(rename_all = "snake_case", tag = "role")]
+pub enum BgpNode {
+ Internal(BgpNodeProperties),
+ External(ExternalBgpNode),
+}
+
+impl ApiType for BgpNode {
+ const API_SCHEMA: Schema = OneOfSchema::new(
+ "BGP node",
+ &(
+ "role",
+ false,
+ &StringSchema::new("internal or external").schema(),
+ ),
+ &[
+ ("external", &ExternalBgpNode::API_SCHEMA),
+ ("internal", &BgpNodeProperties::API_SCHEMA),
+ ],
+ )
+ .schema();
+}
+
+impl Validatable for NodeSection<BgpNode> {
+ type Error = FabricConfigError;
+
+ fn validate(&self) -> Result<(), Self::Error> {
+ if self.ip().is_none() && self.ip6().is_none() {
+ return Err(FabricConfigError::NodeNoIp(self.id().to_string()));
+ }
+ Ok(())
+ }
+}
+
+#[api(
+ properties: {
+ interfaces: {
+ type: Array,
+ optional: true,
+ items: {
+ type: String,
+ description: "Properties for an Bgp interface.",
+ format: &ApiStringFormat::PropertyString(&BgpInterfaceProperties::API_SCHEMA),
+ }
+ },
+ }
+)]
+#[derive(Debug, Clone, Serialize, Deserialize, Updater, Hash)]
+/// Properties for an Bgp node.
+pub struct BgpNodeProperties {
+ /// Autonomous system number for this Node
+ pub(crate) asn: ASN,
+ /// Interfaces for this Node.
+ #[serde(default)]
+ pub(crate) interfaces: Vec<PropertyString<BgpInterfaceProperties>>,
+}
+
+impl BgpNodeProperties {
+ /// Returns the ASN for this node.
+ pub fn asn(&self) -> &ASN {
+ &self.asn
+ }
+
+ /// Returns an iterator over all the interfaces.
+ pub fn interfaces(&self) -> impl Iterator<Item = &BgpInterfaceProperties> {
+ self.interfaces
+ .iter()
+ .map(|property_string| property_string.deref())
+ }
+
+ /// Returns an iterator over all the interfaces (mutable).
+ pub fn interfaces_mut(&mut self) -> impl Iterator<Item = &mut BgpInterfaceProperties> {
+ self.interfaces
+ .iter_mut()
+ .map(|property_string| property_string.deref_mut())
+ }
+}
+
+impl Validatable for NodeSection<BgpNodeProperties> {
+ type Error = FabricConfigError;
+
+ /// Validate the [`NodeSection<BgpNodeProperties>`].
+ fn validate(&self) -> Result<(), Self::Error> {
+ Ok(())
+ }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case", untagged)]
+pub enum BgpNodeDeletableProperties {
+ Interfaces,
+}
+
+#[api]
+#[derive(Debug, Clone, Serialize, Deserialize, Updater, Hash)]
+/// Properties for an BGP interface.
+pub struct BgpInterfaceProperties {
+ pub(crate) name: InterfaceName,
+}
+
+impl BgpInterfaceProperties {
+ /// Get the name of the BGP interface.
+ pub fn name(&self) -> &InterfaceName {
+ &self.name
+ }
+
+ /// Set the name of the interface.
+ pub fn set_name(&mut self, name: InterfaceName) {
+ self.name = name
+ }
+}
+
+/// Derive a deterministic BGP router-id from an IPv6 address using FNV-1a.
+///
+/// BGP router-id must be a 32-bit value. For IPv6-only nodes, we hash the
+/// full 16 octets down to 4 bytes. Typical loopback allocations (sequential
+/// within a prefix, sparse across /48s) produce zero collisions up to 100k
+/// nodes in testing -- well below the random birthday bound (~1% at 10k)
+/// because structured addresses spread well under FNV-1a.
+pub fn router_id_from_ipv6(addr: &Ipv6Addr) -> StdIpv4Addr {
+ let mut hash: u32 = 0x811c9dc5;
+ for &byte in &addr.octets() {
+ hash ^= byte as u32;
+ hash = hash.wrapping_mul(0x01000193);
+ }
+ StdIpv4Addr::from(hash)
+}
diff --git a/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs
index c1ec847..8f918ef 100644
--- a/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs
+++ b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/mod.rs
@@ -1,2 +1,3 @@
+pub mod bgp;
pub mod openfabric;
pub mod ospf;
diff --git a/proxmox-ve-config/tests/fabric/cfg/bgp_default/fabrics.cfg b/proxmox-ve-config/tests/fabric/cfg/bgp_default/fabrics.cfg
new file mode 100644
index 0000000..bd434a7
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/cfg/bgp_default/fabrics.cfg
@@ -0,0 +1,17 @@
+bgp_fabric: test
+ bfd 0
+ ip_prefix 10.10.10.0/24
+
+bgp_node: test_pve
+ asn 65001
+ interfaces name=ens18
+ interfaces name=ens19
+ ip 10.10.10.1
+ role internal
+
+bgp_node: test_pve1
+ asn 65002
+ interfaces name=ens19
+ ip 10.10.10.2
+ role internal
+
diff --git a/proxmox-ve-config/tests/fabric/cfg/bgp_ipv6_only/fabrics.cfg b/proxmox-ve-config/tests/fabric/cfg/bgp_ipv6_only/fabrics.cfg
new file mode 100644
index 0000000..f4581fb
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/cfg/bgp_ipv6_only/fabrics.cfg
@@ -0,0 +1,17 @@
+bgp_fabric: test
+ bfd 0
+ ip6_prefix fd00:10::/64
+
+bgp_node: test_pve
+ asn 65001
+ interfaces name=ens18
+ interfaces name=ens19
+ ip6 fd00:10::1
+ role internal
+
+bgp_node: test_pve1
+ asn 65002
+ interfaces name=ens19
+ ip6 fd00:10::2
+ role internal
+
diff --git a/proxmox-ve-config/tests/fabric/main.rs b/proxmox-ve-config/tests/fabric/main.rs
index 95b2e62..49c5fcc 100644
--- a/proxmox-ve-config/tests/fabric/main.rs
+++ b/proxmox-ve-config/tests/fabric/main.rs
@@ -1,7 +1,9 @@
#![cfg(feature = "frr")]
+use std::net::Ipv4Addr;
use std::str::FromStr;

-use proxmox_frr::ser::{serializer::dump, FrrConfig};
+use proxmox_frr::ser::bgp::{AddressFamilies, BgpRouter, CommonAddressFamilyOptions, L2vpnEvpnAF};
+use proxmox_frr::ser::{serializer::dump, FrrConfig, VrfName};
use proxmox_ve_config::sdn::fabric::{
frr::build_fabric, section_config::node::NodeId, FabricConfig,
};
@@ -162,3 +164,118 @@ fn openfabric_ipv6_only() {

insta::assert_snapshot!(helper::reference_name!("pve"), output);
}
+
+#[test]
+fn bgp_default() {
+ let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
+ let mut frr_config = FrrConfig::default();
+
+ build_fabric(
+ NodeId::from_string("pve".to_owned()).expect("invalid nodeid"),
+ config.clone(),
+ &mut frr_config,
+ )
+ .unwrap();
+
+ let mut output = dump(&frr_config).expect("error dumping stuff");
+
+ insta::assert_snapshot!(helper::reference_name!("pve"), output);
+
+ frr_config = FrrConfig::default();
+ build_fabric(
+ NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"),
+ config,
+ &mut frr_config,
+ )
+ .unwrap();
+
+ output = dump(&frr_config).expect("error dumping stuff");
+
+ insta::assert_snapshot!(helper::reference_name!("pve1"), output);
+}
+
+#[test]
+fn bgp_ipv6_only() {
+ let config = FabricConfig::parse_section_config(helper::get_fabrics_config!()).unwrap();
+ let mut frr_config = FrrConfig::default();
+
+ build_fabric(
+ NodeId::from_string("pve".to_owned()).expect("invalid nodeid"),
+ config.clone(),
+ &mut frr_config,
+ )
+ .unwrap();
+
+ let mut output = dump(&frr_config).expect("error dumping stuff");
+
+ insta::assert_snapshot!(helper::reference_name!("pve"), output);
+
+ frr_config = FrrConfig::default();
+ build_fabric(
+ NodeId::from_string("pve1".to_owned()).expect("invalid nodeid"),
+ config,
+ &mut frr_config,
+ )
+ .unwrap();
+
+ output = dump(&frr_config).expect("error dumping stuff");
+
+ insta::assert_snapshot!(helper::reference_name!("pve1"), output);
+}
+
+/// Test that build_fabric merges into an existing EVPN router and sets local-as
+/// when the ASNs differ.
+#[test]
+fn bgp_merge_with_evpn() {
+ let raw = std::fs::read_to_string("tests/fabric/cfg/bgp_default/fabrics.cfg")
+ .expect("cannot find config file");
+ let config = FabricConfig::parse_section_config(&raw).unwrap();
+
+ // Pre-populate with an EVPN-like router using a different ASN
+ let mut frr_config = FrrConfig::default();
+ let evpn_router = BgpRouter {
+ asn: 65000,
+ router_id: Ipv4Addr::new(10, 10, 10, 1),
+ coalesce_time: Some(1000),
+ default_ipv4_unicast: Some(false),
+ hard_administrative_reset: None,
+ graceful_restart_notification: None,
+ disable_ebgp_connected_route_check: None,
+ bestpath_as_path_multipath_relax: None,
+ neighbor_groups: Vec::new(),
+ address_families: AddressFamilies {
+ ipv4_unicast: None,
+ ipv6_unicast: None,
+ l2vpn_evpn: Some(L2vpnEvpnAF {
+ common_options: CommonAddressFamilyOptions {
+ import_vrf: Vec::new(),
+ neighbors: Vec::new(),
+ custom_frr_config: Vec::new(),
+ },
+ advertise_all_vni: Some(true),
+ advertise_default_gw: None,
+ default_originate: Vec::new(),
+ advertise_ipv4_unicast: None,
+ advertise_ipv6_unicast: None,
+ autort_as: None,
+ route_targets: None,
+ }),
+ },
+ custom_frr_config: Vec::new(),
+ };
+ frr_config
+ .bgp
+ .vrf_router
+ .insert(VrfName::Default, evpn_router);
+
+ build_fabric(
+ NodeId::from_str("pve").expect("invalid nodeid"),
+ config,
+ &mut frr_config,
+ )
+ .unwrap();
+
+ let output = dump(&frr_config).expect("error dumping stuff");
+
+ insta::assert_snapshot!(helper::reference_name!("pve"), output);
+}
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve.snap
new file mode 100644
index 0000000..34aa2cc
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve.snap
@@ -0,0 +1,28 @@
+---
+source: proxmox-ve-config/tests/fabric/main.rs
+expression: output
+---
+!
+router bgp 65001
+ bgp router-id 10.10.10.1
+ no bgp default ipv4-unicast
+ neighbor test peer-group
+ neighbor test remote-as external
+ neighbor ens18 interface peer-group test
+ neighbor ens19 interface peer-group test
+ !
+ address-family ipv4 unicast
+ network 10.10.10.1/32
+ neighbor test activate
+ neighbor test soft-reconfiguration inbound
+ exit-address-family
+exit
+!
+access-list pve_bgp_test_ips permit 10.10.10.0/24
+!
+route-map pve_bgp permit 100
+ match ip address pve_bgp_test_ips
+ set src 10.10.10.1
+exit
+!
+ip protocol bgp route-map pve_bgp
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve1.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve1.snap
new file mode 100644
index 0000000..a3e6b6d
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve1.snap
@@ -0,0 +1,27 @@
+---
+source: proxmox-ve-config/tests/fabric/main.rs
+expression: output
+---
+!
+router bgp 65002
+ bgp router-id 10.10.10.2
+ no bgp default ipv4-unicast
+ neighbor test peer-group
+ neighbor test remote-as external
+ neighbor ens19 interface peer-group test
+ !
+ address-family ipv4 unicast
+ network 10.10.10.2/32
+ neighbor test activate
+ neighbor test soft-reconfiguration inbound
+ exit-address-family
+exit
+!
+access-list pve_bgp_test_ips permit 10.10.10.0/24
+!
+route-map pve_bgp permit 100
+ match ip address pve_bgp_test_ips
+ set src 10.10.10.2
+exit
+!
+ip protocol bgp route-map pve_bgp
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve.snap
new file mode 100644
index 0000000..f335a4f
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve.snap
@@ -0,0 +1,29 @@
+---
+source: proxmox-ve-config/tests/fabric/main.rs
+expression: output
+---
+!
+router bgp 65001
+ bgp router-id 5.76.46.251
+ no bgp default ipv4-unicast
+ neighbor test peer-group
+ neighbor test remote-as external
+ neighbor ens18 interface peer-group test
+ neighbor ens19 interface peer-group test
+ !
+ address-family ipv6 unicast
+ network fd00:10::1/128
+ neighbor test activate
+ neighbor test soft-reconfiguration inbound
+ exit-address-family
+exit
+!
+ipv6 access-list pve_bgp_test_ip6s permit fd00:10::/64
+!
+route-map pve_bgp6 permit 100
+ match ipv6 address pve_bgp_test_ip6s
+ set src fd00:10::1
+exit
+!
+!
+ipv6 protocol bgp route-map pve_bgp6
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve1.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve1.snap
new file mode 100644
index 0000000..67628a9
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve1.snap
@@ -0,0 +1,28 @@
+---
+source: proxmox-ve-config/tests/fabric/main.rs
+expression: output
+---
+!
+router bgp 65002
+ bgp router-id 6.76.48.142
+ no bgp default ipv4-unicast
+ neighbor test peer-group
+ neighbor test remote-as external
+ neighbor ens19 interface peer-group test
+ !
+ address-family ipv6 unicast
+ network fd00:10::2/128
+ neighbor test activate
+ neighbor test soft-reconfiguration inbound
+ exit-address-family
+exit
+!
+ipv6 access-list pve_bgp_test_ip6s permit fd00:10::/64
+!
+route-map pve_bgp6 permit 100
+ match ipv6 address pve_bgp_test_ip6s
+ set src fd00:10::2
+exit
+!
+!
+ipv6 protocol bgp route-map pve_bgp6
diff --git a/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_merge_with_evpn_pve.snap b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_merge_with_evpn_pve.snap
new file mode 100644
index 0000000..ef57cd6
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_merge_with_evpn_pve.snap
@@ -0,0 +1,34 @@
+---
+source: proxmox-ve-config/tests/fabric/main.rs
+expression: output
+---
+!
+router bgp 65000
+ bgp router-id 10.10.10.1
+ no bgp default ipv4-unicast
+ coalesce-time 1000
+ neighbor test peer-group
+ neighbor test remote-as external
+ neighbor test local-as 65001 no-prepend replace-as
+ neighbor ens18 interface peer-group test
+ neighbor ens19 interface peer-group test
+ !
+ address-family ipv4 unicast
+ network 10.10.10.1/32
+ neighbor test activate
+ neighbor test soft-reconfiguration inbound
+ exit-address-family
+ !
+ address-family l2vpn evpn
+ advertise-all-vni
+ exit-address-family
+exit
+!
+access-list pve_bgp_test_ips permit 10.10.10.0/24
+!
+route-map pve_bgp permit 100
+ match ip address pve_bgp_test_ips
+ set src 10.10.10.1
+exit
+!
+ip protocol bgp route-map pve_bgp
--
2.47.3