public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ve-rs v2 1/7] sdn: fabric: add BGP protocol support
Date: Wed, 15 Apr 2026 13:11:28 +0200	[thread overview]
Message-ID: <20260415111134.124720-2-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260415111134.124720-1-h.laimer@proxmox.com>

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





  reply	other threads:[~2026-04-15 11:11 UTC|newest]

Thread overview: 8+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-04-15 11:11 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v2 0/7] sdn: add BGP fabric Hannes Laimer
2026-04-15 11:11 ` Hannes Laimer [this message]
2026-04-15 11:11 ` [PATCH proxmox-perl-rs v2 2/7] sdn: fabrics: add BGP config generation Hannes Laimer
2026-04-15 11:11 ` [PATCH proxmox-perl-rs v2 3/7] sdn: fabrics: add BGP status endpoints Hannes Laimer
2026-04-15 11:11 ` [PATCH pve-network v2 4/7] sdn: fabrics: register bgp as a fabric protocol type Hannes Laimer
2026-04-15 11:11 ` [PATCH pve-network v2 5/7] test: evpn: add integration test for EVPN over BGP fabric Hannes Laimer
2026-04-15 11:11 ` [PATCH pve-manager v2 6/7] ui: sdn: add BGP fabric support Hannes Laimer
2026-04-15 11:11 ` [PATCH pve-docs v2 7/7] sdn: add bgp fabric section Hannes Laimer

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20260415111134.124720-2-h.laimer@proxmox.com \
    --to=h.laimer@proxmox.com \
    --cc=pve-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal