all lists on 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 v5 1/8] sdn: fabric: add BGP protocol support
Date: Wed, 13 May 2026 20:42:06 +0200	[thread overview]
Message-ID: <20260513184213.506775-2-h.laimer@proxmox.com> (raw)
In-Reply-To: <20260513184213.506775-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.

FRR allows only one BGP instance per VRF. The fabric underlay and the
EVPN overlay therefore have to coexist in the default VRF's BGP
instance, so the fabric merges into the 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                    |  87 ++++-
 proxmox-ve-config/src/sdn/fabric/frr.rs       | 304 +++++++++++++++++-
 proxmox-ve-config/src/sdn/fabric/mod.rs       | 169 +++++++++-
 .../src/sdn/fabric/section_config/fabric.rs   |  22 ++
 .../src/sdn/fabric/section_config/mod.rs      |  21 +-
 .../src/sdn/fabric/section_config/node.rs     |  21 ++
 .../sdn/fabric/section_config/protocol/bgp.rs | 287 +++++++++++++++++
 .../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    |  36 +++
 .../snapshots/fabric__bgp_default_pve1.snap   |  35 ++
 .../snapshots/fabric__bgp_ipv6_only_pve.snap  |  37 +++
 .../snapshots/fabric__bgp_ipv6_only_pve1.snap |  36 +++
 .../fabric__bgp_merge_with_evpn_pve.snap      |  42 +++
 16 files changed, 1238 insertions(+), 13 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 c1b4466..a17f7c2 100644
--- a/proxmox-frr/src/ser/bgp.rs
+++ b/proxmox-frr/src/ser/bgp.rs
@@ -43,15 +43,15 @@ 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)]
 pub struct NeighborGroup {
     pub name: FrrWord,
-    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
+    #[serde(default, deserialize_with = "proxmox_serde::perl::deserialize_bool")]
     pub bfd: bool,
     #[serde(default)]
     pub local_as: Option<LocalAsSettings>,
@@ -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 {
+    /// Extend this [`AddressFamilies`] with another.
+    ///
+    /// 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 extend(&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.extend(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 40e346f..d70d5aa 100644
--- a/proxmox-ve-config/src/sdn/fabric/frr.rs
+++ b/proxmox-ve-config/src/sdn/fabric/frr.rs
@@ -2,15 +2,25 @@ 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, OspfRedistribution, OspfRouter};
-use proxmox_frr::ser::route_map::{AccessListName, RouteMapEntry, RouteMapMatch, RouteMapSet};
-use proxmox_frr::ser::{self, FrrConfig, FrrProtocol, FrrWord, Interface, InterfaceName};
-use proxmox_network_types::ip_address::Cidr;
+use proxmox_frr::ser::route_map::{
+    AccessListName, RouteMapEntry, RouteMapMatch, RouteMapName, RouteMapSet,
+};
+use proxmox_frr::ser::{self, FrrConfig, FrrProtocol, FrrWord, Interface, InterfaceName, VrfName};
+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::{bgp_router_id, BgpNode};
 use crate::sdn::fabric::section_config::protocol::{
+    bgp::BgpRedistributionSource,
     openfabric::{OpenfabricInterfaceProperties, OpenfabricProperties},
     ospf::OspfInterfaceProperties,
 };
@@ -289,6 +299,294 @@ pub fn build_fabric(
                 protocol_routemap.v4 = Some(routemap_name);
             }
             FabricEntry::WireGuard(_) => {} // not a frr 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::from),
+                    })
+                    .collect();
+
+                let mut address_families = AddressFamilies::default();
+
+                if let Some(ip) = node.ip() {
+                    // Build the prefix matcher (route_filter prefix-list, or
+                    // an auto access-list from ip_prefix). Reused for both the
+                    // pve_bgp set-src clause and the per-peer inbound filter
+                    // below, so they stay in sync.
+                    let inbound_matchers: Vec<RouteMapMatch> =
+                        if let Some(prefix_list_id) = &fabric.properties().route_filter {
+                            vec![RouteMapMatch::IpAddressPrefixList(
+                                prefix_list_id.clone().into(),
+                            )]
+                        } else if let Some(cidr) = fabric.ip_prefix() {
+                            let access_list_name =
+                                AccessListName::new(format!("pve_bgp_{fabric_id}_ips"));
+
+                            let rule = ser::route_map::AccessListRule {
+                                action: ser::route_map::AccessAction::Permit,
+                                network: Cidr::from(cidr),
+                                is_ipv6: false,
+                                seq: None,
+                            };
+
+                            frr_config
+                                .access_lists
+                                .insert(access_list_name.clone(), vec![rule]);
+
+                            vec![RouteMapMatch::IpAddressAccessList(access_list_name)]
+                        } else {
+                            Vec::new()
+                        };
+
+                    // Per-peer inbound filter: permit prefixes matching the fabric's filter,
+                    // implicit deny everything else. Stops a misbehaving fabric peer from leaking
+                    // prefixes outside its declared range into BGP at all. If the user configured
+                    // a custom route_map_in, it is chained via FRR's `call` action so it only sees
+                    // prefixes that already passed the fabric-prefix filter.
+                    let auto_in_routemap = if !inbound_matchers.is_empty() {
+                        let name =
+                            ser::route_map::RouteMapName::new(format!("pve_bgp_{fabric_id}_in"));
+                        let in_routemap = frr_config.routemaps.entry(name.clone()).or_default();
+                        in_routemap.push(RouteMapEntry {
+                            seq: 10,
+                            action: ser::route_map::AccessAction::Permit,
+                            matches: inbound_matchers.clone(),
+                            sets: Vec::new(),
+                            custom_frr_config: Vec::new(),
+                            call: fabric
+                                .properties()
+                                .route_map_in
+                                .clone()
+                                .map(RouteMapName::from),
+                            exit_action: None,
+                        });
+                        Some(name)
+                    } else {
+                        None
+                    };
+
+                    address_families.ipv4_unicast = Some(Ipv4UnicastAF {
+                        common_options: CommonAddressFamilyOptions {
+                            import_vrf: Default::default(),
+                            neighbors: vec![AddressFamilyNeighbor {
+                                name: fabric.id().to_string(),
+                                route_map_in: auto_in_routemap,
+                                route_map_out: fabric
+                                    .properties()
+                                    .route_map_out
+                                    .clone()
+                                    .map(RouteMapName::from),
+                                soft_reconfiguration_inbound: Some(true),
+                            }],
+                            custom_frr_config: Default::default(),
+                        },
+                        redistribute: redistribute.clone(),
+                        networks: vec![Ipv4Cidr::from(ip)],
+                    });
+
+                    let routemap_name = ser::route_map::RouteMapName::new("pve_bgp".to_owned());
+                    let routemap = frr_config
+                        .routemaps
+                        .entry(routemap_name.clone())
+                        .or_default();
+
+                    let mut routemap_entry = build_source_routemap(ip.into(), routemap_seq);
+                    routemap_seq += 10;
+                    routemap_entry.matches = inbound_matchers;
+
+                    routemap.push(routemap_entry);
+
+                    let protocol_routemap = frr_config
+                        .protocol_routemaps
+                        .entry(FrrProtocol::Bgp)
+                        .or_default();
+
+                    protocol_routemap.v4 = Some(routemap_name);
+                }
+
+                if let Some(ip) = node.ip6() {
+                    let inbound_matchers: Vec<RouteMapMatch> =
+                        if let Some(prefix_list_id) = &fabric.properties().route_filter {
+                            vec![RouteMapMatch::Ip6AddressPrefixList(
+                                prefix_list_id.clone().into(),
+                            )]
+                        } else if let Some(cidr) = fabric.ip6_prefix() {
+                            let access_list_name =
+                                AccessListName::new(format!("pve_bgp_{fabric_id}_ip6s"));
+
+                            let rule = ser::route_map::AccessListRule {
+                                action: ser::route_map::AccessAction::Permit,
+                                network: Cidr::from(cidr),
+                                is_ipv6: true,
+                                seq: None,
+                            };
+
+                            frr_config
+                                .access_lists
+                                .insert(access_list_name.clone(), vec![rule]);
+
+                            vec![RouteMapMatch::Ip6AddressAccessList(access_list_name)]
+                        } else {
+                            Vec::new()
+                        };
+
+                    let auto_in_routemap = if !inbound_matchers.is_empty() {
+                        let name =
+                            ser::route_map::RouteMapName::new(format!("pve_bgp6_{fabric_id}_in"));
+                        let in_routemap = frr_config.routemaps.entry(name.clone()).or_default();
+                        in_routemap.push(RouteMapEntry {
+                            seq: 10,
+                            action: ser::route_map::AccessAction::Permit,
+                            matches: inbound_matchers.clone(),
+                            sets: Vec::new(),
+                            custom_frr_config: Vec::new(),
+                            call: fabric
+                                .properties()
+                                .route_map_in
+                                .clone()
+                                .map(RouteMapName::from),
+                            exit_action: None,
+                        });
+                        Some(name)
+                    } else {
+                        None
+                    };
+
+                    address_families.ipv6_unicast = Some(Ipv6UnicastAF {
+                        common_options: CommonAddressFamilyOptions {
+                            import_vrf: Default::default(),
+                            neighbors: vec![AddressFamilyNeighbor {
+                                name: fabric.id().to_string(),
+                                route_map_in: auto_in_routemap,
+                                route_map_out: fabric
+                                    .properties()
+                                    .route_map_out
+                                    .clone()
+                                    .map(RouteMapName::from),
+                                soft_reconfiguration_inbound: Some(true),
+                            }],
+                            custom_frr_config: Default::default(),
+                        },
+                        networks: vec![Ipv6Cidr::from(ip)],
+                        redistribute,
+                    });
+
+                    let routemap_name = ser::route_map::RouteMapName::new("pve_bgp6".to_owned());
+                    let routemap = frr_config
+                        .routemaps
+                        .entry(routemap_name.clone())
+                        .or_default();
+
+                    let mut routemap_entry = build_source_routemap(ip.into(), routemap_seq);
+                    routemap_seq += 10;
+                    routemap_entry.matches = inbound_matchers;
+
+                    routemap.push(routemap_entry);
+
+                    let protocol_routemap = frr_config
+                        .protocol_routemaps
+                        .entry(FrrProtocol::Bgp)
+                        .or_default();
+
+                    protocol_routemap.v6 = Some(routemap_name);
+                };
+
+                let router_id = bgp_router_id(&node)
+                    .ok_or_else(|| anyhow::anyhow!("BGP node must have ip or ip6 set"))?;
+
+                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);
+                }
+            }
+        }
+    }
+
+    // Append a trailing permit-all to the BGP route-maps so non-fabric BGP
+    // routes (e.g. EVPN-imported VRF routes) reach the kernel unchanged.
+    // Without this, the implicit deny at the end of the route-map would drop
+    // them.
+    for routemap_name in [
+        ser::route_map::RouteMapName::new("pve_bgp".to_owned()),
+        ser::route_map::RouteMapName::new("pve_bgp6".to_owned()),
+    ] {
+        if let Some(routemap) = frr_config.routemaps.get_mut(&routemap_name) {
+            routemap.push(RouteMapEntry {
+                seq: 65535,
+                action: ser::route_map::AccessAction::Permit,
+                matches: Vec::new(),
+                sets: Vec::new(),
+                custom_frr_config: Vec::new(),
+                call: None,
+                exit_action: None,
+            });
         }
     }
 
diff --git a/proxmox-ve-config/src/sdn/fabric/mod.rs b/proxmox-ve-config/src/sdn/fabric/mod.rs
index 146563e..6c88af2 100644
--- a/proxmox-ve-config/src/sdn/fabric/mod.rs
+++ b/proxmox-ve-config/src/sdn/fabric/mod.rs
@@ -2,7 +2,7 @@
 pub mod frr;
 pub mod section_config;
 
-use std::collections::{BTreeMap, HashSet};
+use std::collections::{BTreeMap, HashMap, HashSet};
 use std::marker::PhantomData;
 use std::ops::Deref;
 
@@ -21,6 +21,10 @@ use crate::sdn::fabric::section_config::node::{
     api::{NodeDataUpdater, NodeDeletableProperties, NodeUpdater},
     Node, NodeId, NodeSection,
 };
+use crate::sdn::fabric::section_config::protocol::bgp::{
+    bgp_router_id, BgpDeletableProperties, BgpNode, BgpNodeDeletableProperties,
+    BgpNodePropertiesUpdater, BgpProperties, BgpPropertiesUpdater,
+};
 use crate::sdn::fabric::section_config::protocol::openfabric::{
     OpenfabricDeletableProperties, OpenfabricNodeDeletableProperties, OpenfabricNodeProperties,
     OpenfabricNodePropertiesUpdater, OpenfabricProperties, OpenfabricPropertiesUpdater,
@@ -69,6 +73,8 @@ pub enum FabricConfigError {
     // this is technically possible, but we don't allow it
     #[error("duplicate OSPF area")]
     DuplicateOspfArea,
+    #[error("BGP router-id collision: multiple nodes resolve to the same 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}'")]
@@ -203,6 +209,7 @@ macro_rules! impl_entry {
 impl_entry!(Openfabric, OpenfabricProperties, OpenfabricNodeProperties);
 impl_entry!(Ospf, OspfProperties, OspfNodeProperties);
 impl_entry!(WireGuard, WireGuardProperties, WireGuardNode);
+impl_entry!(Bgp, BgpProperties, BgpNode);
 
 /// All possible entries in a [`FabricConfig`].
 ///
@@ -213,6 +220,7 @@ pub enum FabricEntry {
     Openfabric(Entry<OpenfabricProperties, OpenfabricNodeProperties>),
     Ospf(Entry<OspfProperties, OspfNodeProperties>),
     WireGuard(Entry<WireGuardProperties, WireGuardNode>),
+    Bgp(Entry<BgpProperties, BgpNode>),
 }
 
 impl FabricEntry {
@@ -227,6 +235,7 @@ impl FabricEntry {
             (FabricEntry::WireGuard(entry), Node::WireGuard(node_section)) => {
                 entry.add_node(node_section)
             }
+            (FabricEntry::Bgp(entry), Node::Bgp(node_section)) => entry.add_node(node_section),
             _ => Err(FabricConfigError::ProtocolMismatch),
         }
     }
@@ -238,6 +247,7 @@ impl FabricEntry {
             FabricEntry::Openfabric(entry) => entry.get_node(id),
             FabricEntry::Ospf(entry) => entry.get_node(id),
             FabricEntry::WireGuard(entry) => entry.get_node(id),
+            FabricEntry::Bgp(entry) => entry.get_node(id),
         }
     }
 
@@ -248,6 +258,7 @@ impl FabricEntry {
             FabricEntry::Openfabric(entry) => entry.get_node_mut(id),
             FabricEntry::Ospf(entry) => entry.get_node_mut(id),
             FabricEntry::WireGuard(entry) => entry.get_node_mut(id),
+            FabricEntry::Bgp(entry) => entry.get_node_mut(id),
         }
     }
 
@@ -394,6 +405,8 @@ impl FabricEntry {
                                 _ => continue,
                             }
                         }
+
+                        Ok(())
                     }
                     (
                         WireGuardNode::External(external_wire_guard_node),
@@ -424,8 +437,48 @@ impl FabricEntry {
                                 _ => continue,
                             }
                         }
+
+                        Ok(())
+                    }
+                    _ => Err(FabricConfigError::ProtocolMismatch),
+                }
+            }
+            (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(),
                     }
-                    _ => return Err(FabricConfigError::ProtocolMismatch),
                 }
 
                 Ok(())
@@ -440,6 +493,7 @@ impl FabricEntry {
             FabricEntry::Openfabric(entry) => entry.nodes.iter(),
             FabricEntry::Ospf(entry) => entry.nodes.iter(),
             FabricEntry::WireGuard(entry) => entry.nodes.iter(),
+            FabricEntry::Bgp(entry) => entry.nodes.iter(),
         }
     }
 
@@ -449,6 +503,7 @@ impl FabricEntry {
             FabricEntry::Openfabric(entry) => entry.delete_node(id),
             FabricEntry::Ospf(entry) => entry.delete_node(id),
             FabricEntry::WireGuard(entry) => entry.delete_node(id),
+            FabricEntry::Bgp(entry) => entry.delete_node(id),
         }
     }
 
@@ -459,6 +514,7 @@ impl FabricEntry {
             FabricEntry::Openfabric(entry) => entry.into_pair(),
             FabricEntry::Ospf(entry) => entry.into_pair(),
             FabricEntry::WireGuard(entry) => entry.into_pair(),
+            FabricEntry::Bgp(entry) => entry.into_pair(),
         }
     }
 
@@ -468,6 +524,7 @@ impl FabricEntry {
             FabricEntry::Openfabric(entry) => &entry.fabric,
             FabricEntry::Ospf(entry) => &entry.fabric,
             FabricEntry::WireGuard(entry) => &entry.fabric,
+            FabricEntry::Bgp(entry) => &entry.fabric,
         }
     }
 
@@ -477,6 +534,7 @@ impl FabricEntry {
             FabricEntry::Openfabric(entry) => &mut entry.fabric,
             FabricEntry::Ospf(entry) => &mut entry.fabric,
             FabricEntry::WireGuard(entry) => &mut entry.fabric,
+            FabricEntry::Bgp(entry) => &mut entry.fabric,
         }
     }
 }
@@ -489,6 +547,7 @@ impl From<Fabric> for FabricEntry {
             }
             Fabric::Ospf(fabric_section) => FabricEntry::Ospf(Entry::new(fabric_section)),
             Fabric::WireGuard(fabric_section) => FabricEntry::WireGuard(Entry::new(fabric_section)),
+            Fabric::Bgp(fabric_section) => FabricEntry::Bgp(Entry::new(fabric_section)),
         }
     }
 }
@@ -502,6 +561,8 @@ impl Validatable for FabricEntry {
     /// - Node IP addresses are within their respective fabric IP prefix ranges
     /// - IP addresses are unique across all nodes in the fabric
     /// - Each node passes its own validation checks
+    /// - For BGP fabrics, derived router-ids are unique across nodes (catches
+    ///   FNV-1a hash collisions for IPv6-only nodes)
     fn validate(&self) -> Result<(), FabricConfigError> {
         let fabric = self.fabric();
 
@@ -649,6 +710,27 @@ impl Validatable for FabricEntry {
             }
         }
 
+        // Per-node IPs are unique by the checks above. Router-ids can still
+        // collide when at least one node falls back to FNV-1a on its IPv6
+        // address (the hash is 32 bits wide, so two distinct IPv6 addresses
+        // can map to the same router-id).
+        if let FabricEntry::Bgp(bgp_entry) = self {
+            let mut seen_router_ids: HashMap<std::net::Ipv4Addr, &NodeId> = HashMap::new();
+            for (node_id, node) in &bgp_entry.nodes {
+                let Node::Bgp(node_section) = node else {
+                    continue;
+                };
+                if !matches!(node_section.properties(), BgpNode::Internal(_)) {
+                    continue;
+                }
+                if let Some(router_id) = bgp_router_id(node_section) {
+                    if seen_router_ids.insert(router_id, node_id).is_some() {
+                        return Err(FabricConfigError::DuplicateBgpRouterId(router_id));
+                    }
+                }
+            }
+        }
+
         fabric.validate()
     }
 }
@@ -763,6 +845,15 @@ impl Validatable for FabricConfig {
                             }
                         }
                     }
+                    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);
+                            }
+                        }
+                    }
                 }
             }
 
@@ -990,6 +1081,80 @@ impl FabricConfig {
 
                 Ok(())
             }
+            (Fabric::Bgp(fabric_section), FabricUpdater::Bgp(updater)) => {
+                let FabricSectionUpdater::<BgpPropertiesUpdater, BgpDeletableProperties> {
+                    ip_prefix,
+                    ip6_prefix,
+                    properties:
+                        BgpPropertiesUpdater {
+                            bfd,
+                            redistribute,
+                            route_map_in,
+                            route_map_out,
+                            route_filter,
+                        },
+                    delete,
+                } = updater;
+
+                if let Some(prefix) = ip_prefix {
+                    fabric_section.ip_prefix = Some(prefix);
+                }
+
+                if let Some(prefix) = ip6_prefix {
+                    fabric_section.ip6_prefix = Some(prefix);
+                }
+
+                if let Some(bfd) = bfd {
+                    fabric_section.properties.bfd = bfd;
+                }
+
+                if let Some(redistribute) = redistribute {
+                    fabric_section.properties.redistribute = redistribute;
+                }
+
+                if let Some(route_map_in) = route_map_in {
+                    fabric_section.properties.route_map_in = Some(route_map_in);
+                }
+
+                if let Some(route_map_out) = route_map_out {
+                    fabric_section.properties.route_map_out = Some(route_map_out);
+                }
+
+                if let Some(route_filter) = route_filter {
+                    fabric_section.properties.route_filter = Some(route_filter);
+                }
+
+                for property in delete {
+                    match property {
+                        FabricDeletableProperties::IpPrefix => {
+                            fabric_section.ip_prefix = None;
+                        }
+                        FabricDeletableProperties::Ip6Prefix => {
+                            fabric_section.ip6_prefix = None;
+                        }
+                        FabricDeletableProperties::Protocol(
+                            BgpDeletableProperties::Redistribute,
+                        ) => {
+                            fabric_section.properties.redistribute = Vec::new();
+                        }
+                        FabricDeletableProperties::Protocol(
+                            BgpDeletableProperties::RouteFilter,
+                        ) => {
+                            fabric_section.properties.route_filter = None;
+                        }
+                        FabricDeletableProperties::Protocol(BgpDeletableProperties::RouteMapIn) => {
+                            fabric_section.properties.route_map_in = None;
+                        }
+                        FabricDeletableProperties::Protocol(
+                            BgpDeletableProperties::RouteMapOut,
+                        ) => {
+                            fabric_section.properties.route_map_out = None;
+                        }
+                    }
+                }
+
+                Ok(())
+            }
             _ => Err(FabricConfigError::ProtocolMismatch),
         }
     }
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 e92074c..efa186a 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,
 };
@@ -147,6 +150,10 @@ impl UpdaterType for FabricSection<WireGuardProperties> {
     type Updater = FabricSectionUpdater<WireGuardPropertiesUpdater, WireGuardDeletableProperties>;
 }
 
+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
@@ -169,6 +176,7 @@ pub enum Fabric {
     Ospf(FabricSection<OspfProperties>),
     #[serde(rename = "wireguard")]
     WireGuard(FabricSection<WireGuardProperties>),
+    Bgp(FabricSection<BgpProperties>),
 }
 
 impl UpdaterType for Fabric {
@@ -184,6 +192,7 @@ impl Fabric {
             Self::Openfabric(fabric_section) => fabric_section.id(),
             Self::Ospf(fabric_section) => fabric_section.id(),
             Self::WireGuard(fabric_section) => fabric_section.id(),
+            Self::Bgp(fabric_section) => fabric_section.id(),
         }
     }
 
@@ -195,6 +204,7 @@ impl Fabric {
             Fabric::Openfabric(fabric_section) => fabric_section.ip_prefix(),
             Fabric::Ospf(fabric_section) => fabric_section.ip_prefix(),
             Fabric::WireGuard(fabric_section) => fabric_section.ip_prefix(),
+            Fabric::Bgp(fabric_section) => fabric_section.ip_prefix(),
         }
     }
 
@@ -206,6 +216,7 @@ impl Fabric {
             Fabric::Openfabric(fabric_section) => fabric_section.ip_prefix = Some(ipv4_cidr),
             Fabric::Ospf(fabric_section) => fabric_section.ip_prefix = Some(ipv4_cidr),
             Fabric::WireGuard(fabric_section) => fabric_section.ip_prefix = Some(ipv4_cidr),
+            Fabric::Bgp(fabric_section) => fabric_section.ip_prefix = Some(ipv4_cidr),
         }
     }
 
@@ -217,6 +228,7 @@ impl Fabric {
             Fabric::Openfabric(fabric_section) => fabric_section.ip6_prefix(),
             Fabric::Ospf(fabric_section) => fabric_section.ip6_prefix(),
             Fabric::WireGuard(fabric_section) => fabric_section.ip6_prefix(),
+            Fabric::Bgp(fabric_section) => fabric_section.ip6_prefix(),
         }
     }
 
@@ -228,6 +240,7 @@ impl Fabric {
             Fabric::Openfabric(fabric_section) => fabric_section.ip6_prefix = Some(ipv6_cidr),
             Fabric::Ospf(fabric_section) => fabric_section.ip6_prefix = Some(ipv6_cidr),
             Fabric::WireGuard(fabric_section) => fabric_section.ip6_prefix = Some(ipv6_cidr),
+            Fabric::Bgp(fabric_section) => fabric_section.ip6_prefix = Some(ipv6_cidr),
         }
     }
 }
@@ -241,6 +254,7 @@ impl Validatable for Fabric {
             Fabric::Openfabric(fabric_section) => fabric_section.validate(),
             Fabric::Ospf(fabric_section) => fabric_section.validate(),
             Fabric::WireGuard(_fabric_section) => Ok(()),
+            Fabric::Bgp(fabric_section) => fabric_section.validate(),
         }
     }
 }
@@ -263,6 +277,12 @@ impl From<FabricSection<WireGuardProperties>> 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")]
@@ -271,6 +291,7 @@ pub enum FabricUpdater {
     Ospf(<FabricSection<OspfProperties> as UpdaterType>::Updater),
     #[serde(rename = "wireguard")]
     WireGuard(<FabricSection<WireGuardProperties> as UpdaterType>::Updater),
+    Bgp(<FabricSection<BgpProperties> as UpdaterType>::Updater),
 }
 
 impl Updater for FabricUpdater {
@@ -279,6 +300,7 @@ impl Updater for FabricUpdater {
             FabricUpdater::Openfabric(updater) => updater.is_empty(),
             FabricUpdater::Ospf(updater) => updater.is_empty(),
             FabricUpdater::WireGuard(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 f47a522..f85c547 100644
--- a/proxmox-ve-config/src/sdn/fabric/section_config/mod.rs
+++ b/proxmox-ve-config/src/sdn/fabric/section_config/mod.rs
@@ -11,6 +11,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},
         wireguard::WireGuardNode,
@@ -34,9 +35,11 @@ impl From<Section> for FabricOrNode<Fabric, Node> {
             Section::OpenfabricFabric(fabric_section) => Self::Fabric(fabric_section.into()),
             Section::OspfFabric(fabric_section) => Self::Fabric(fabric_section.into()),
             Section::WireGuardFabric(fabric_section) => Self::Fabric(fabric_section.into()),
+            Section::BgpFabric(fabric_section) => Self::Fabric(fabric_section.into()),
+            Section::WireGuardNode(node_section) => Self::Node(node_section.into()),
             Section::OpenfabricNode(node_section) => Self::Node(node_section.into()),
             Section::OspfNode(node_section) => Self::Node(node_section.into()),
-            Section::WireGuardNode(node_section) => Self::Node(node_section.into()),
+            Section::BgpNode(node_section) => Self::Node(node_section.into()),
         }
     }
 }
@@ -68,10 +71,12 @@ pub enum Section {
     OspfFabric(FabricSection<OspfProperties>),
     #[serde(rename = "wireguard_fabric")]
     WireGuardFabric(FabricSection<WireGuardProperties>),
+    BgpFabric(FabricSection<BgpProperties>),
     OpenfabricNode(NodeSection<OpenfabricNodeProperties>),
     OspfNode(NodeSection<OspfNodeProperties>),
     #[serde(rename = "wireguard_node")]
     WireGuardNode(NodeSection<WireGuardNode>),
+    BgpNode(NodeSection<BgpNode>),
 }
 
 impl From<FabricSection<OpenfabricProperties>> for Section {
@@ -92,6 +97,12 @@ impl From<FabricSection<WireGuardProperties>> 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)
@@ -110,12 +121,19 @@ impl From<NodeSection<WireGuardNode>> 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::WireGuard(fabric_section) => fabric_section.into(),
+            Fabric::Bgp(fabric_section) => fabric_section.into(),
         }
     }
 }
@@ -126,6 +144,7 @@ impl From<Node> for Section {
             Node::Openfabric(node_section) => node_section.into(),
             Node::Ospf(node_section) => node_section.into(),
             Node::WireGuard(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 f2300ac..af15898 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::protocol::wireguard::WireGuardNode;
 use crate::sdn::fabric::section_config::{
     fabric::{FabricId, FABRIC_ID_REGEX_STR},
@@ -191,6 +192,7 @@ pub enum Node {
     Ospf(NodeSection<OspfNodeProperties>),
     #[serde(rename = "wireguard")]
     WireGuard(NodeSection<WireGuardNode>),
+    Bgp(NodeSection<BgpNode>),
 }
 
 impl Node {
@@ -200,6 +202,7 @@ impl Node {
             Node::Openfabric(node_section) => node_section.id(),
             Node::Ospf(node_section) => node_section.id(),
             Node::WireGuard(node_section) => node_section.id(),
+            Node::Bgp(node_section) => node_section.id(),
         }
     }
 
@@ -209,6 +212,7 @@ impl Node {
             Node::Openfabric(node_section) => node_section.ip(),
             Node::Ospf(node_section) => node_section.ip(),
             Node::WireGuard(node_section) => node_section.ip(),
+            Node::Bgp(node_section) => node_section.ip(),
         }
     }
 
@@ -218,6 +222,7 @@ impl Node {
             Node::Openfabric(node_section) => node_section.ip6(),
             Node::Ospf(node_section) => node_section.ip6(),
             Node::WireGuard(node_section) => node_section.ip6(),
+            Node::Bgp(node_section) => node_section.ip6(),
         }
     }
 }
@@ -230,6 +235,7 @@ impl Validatable for Node {
             Node::Openfabric(node_section) => node_section.validate(),
             Node::Ospf(node_section) => node_section.validate(),
             Node::WireGuard(node_section) => node_section.validate(),
+            Node::Bgp(node_section) => node_section.validate(),
         }
     }
 }
@@ -252,6 +258,12 @@ impl From<NodeSection<WireGuardNode>> 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,
@@ -273,6 +285,7 @@ pub mod api {
     use proxmox_schema::{Updater, UpdaterType};
 
     use crate::sdn::fabric::section_config::protocol::{
+        bgp::{BgpNodeDeletableProperties, BgpNodePropertiesUpdater},
         openfabric::{
             OpenfabricNodeDeletableProperties, OpenfabricNodeProperties,
             OpenfabricNodePropertiesUpdater,
@@ -338,6 +351,7 @@ pub mod api {
         Ospf(NodeData<OspfNodeProperties>),
         #[serde(rename = "wireguard")]
         WireGuard(NodeData<WireGuardNode>),
+        Bgp(NodeData<BgpNode>),
     }
 
     impl From<super::Node> for Node {
@@ -346,6 +360,7 @@ pub mod api {
                 super::Node::Openfabric(node_section) => Self::Openfabric(node_section.into()),
                 super::Node::Ospf(node_section) => Self::Ospf(node_section.into()),
                 super::Node::WireGuard(node_section) => Self::WireGuard(node_section.into()),
+                super::Node::Bgp(node_section) => Self::Bgp(node_section.into()),
             }
         }
     }
@@ -356,6 +371,7 @@ pub mod api {
                 Node::Openfabric(node_section) => Self::Openfabric(node_section.into()),
                 Node::Ospf(node_section) => Self::Ospf(node_section.into()),
                 Node::WireGuard(node_section) => Self::WireGuard(node_section.into()),
+                Node::Bgp(node_section) => Self::Bgp(node_section.into()),
             }
         }
     }
@@ -373,6 +389,10 @@ pub mod api {
         type Updater = NodeDataUpdater<WireGuardNodeUpdater, WireGuardNodeDeletableProperties>;
     }
 
+    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")]
@@ -410,6 +430,7 @@ pub mod api {
         Ospf(NodeDataUpdater<OspfNodePropertiesUpdater, OspfNodeDeletableProperties>),
         #[serde(rename = "wireguard")]
         WireGuard(NodeDataUpdater<WireGuardNodeUpdater, WireGuardNodeDeletableProperties>),
+        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..81d48b1
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/fabric/section_config/protocol/bgp.rs
@@ -0,0 +1,287 @@
+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;
+
+use crate::sdn::prefix_list::PrefixListId;
+use crate::sdn::route_map::RouteMapId;
+
+#[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<RouteMapId>,
+}
+
+#[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>,
+    {
+        proxmox_serde::perl::deserialize_u32(deserializer).map(ASN)
+    }
+}
+
+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>>,
+
+    /// Route map to apply for incoming routes
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) route_map_in: Option<RouteMapId>,
+
+    /// Route map to apply for outgoing routes
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) route_map_out: Option<RouteMapId>,
+
+    /// By default only routes from the configured IP prefix are imported
+    /// into the local routing table. This setting can be used to override the
+    /// allowed IPs and import additional routes besides the configured IP
+    /// prefix.
+    #[serde(skip_serializing_if = "Option::is_none")]
+    pub(crate) route_filter: Option<PrefixListId>,
+}
+
+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> {
+        if self.ip_prefix().is_none() && self.ip6_prefix().is_none() {
+            return Err(FabricConfigError::FabricNoIpPrefix(self.id().to_string()));
+        }
+
+        Ok(())
+    }
+}
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+#[serde(rename_all = "snake_case")]
+pub enum BgpDeletableProperties {
+    Redistribute,
+    RouteFilter,
+    RouteMapIn,
+    RouteMapOut,
+}
+
+#[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> {
+        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)
+}
+
+/// Resolves the BGP router-id for a node: the IPv4 address if set,
+/// otherwise an FNV-1a hash of the IPv6 address.
+pub fn bgp_router_id(node: &NodeSection<BgpNode>) -> Option<StdIpv4Addr> {
+    node.ip()
+        .or_else(|| node.ip6().map(|ipv6| router_id_from_ipv6(&ipv6)))
+}
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 fd77426..c7adf0f 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,3 +1,4 @@
+pub mod bgp;
 pub mod openfabric;
 pub mod ospf;
 pub mod wireguard;
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..0db0034
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve.snap
@@ -0,0 +1,36 @@
+---
+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
+  neighbor test route-map pve_bgp_test_in in
+ 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
+!
+route-map pve_bgp permit 65535
+exit
+!
+route-map pve_bgp_test_in permit 10
+ match ip address pve_bgp_test_ips
+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..d7ed018
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_default_pve1.snap
@@ -0,0 +1,35 @@
+---
+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
+  neighbor test route-map pve_bgp_test_in in
+ 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
+!
+route-map pve_bgp permit 65535
+exit
+!
+route-map pve_bgp_test_in permit 10
+ match ip address pve_bgp_test_ips
+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..8dbb36b
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve.snap
@@ -0,0 +1,37 @@
+---
+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
+  neighbor test route-map pve_bgp6_test_in in
+ 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
+!
+route-map pve_bgp6 permit 65535
+exit
+!
+route-map pve_bgp6_test_in permit 10
+ match ipv6 address pve_bgp_test_ip6s
+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..a091148
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_ipv6_only_pve1.snap
@@ -0,0 +1,36 @@
+---
+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
+  neighbor test route-map pve_bgp6_test_in in
+ 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
+!
+route-map pve_bgp6 permit 65535
+exit
+!
+route-map pve_bgp6_test_in permit 10
+ match ipv6 address pve_bgp_test_ip6s
+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..226337f
--- /dev/null
+++ b/proxmox-ve-config/tests/fabric/snapshots/fabric__bgp_merge_with_evpn_pve.snap
@@ -0,0 +1,42 @@
+---
+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
+  neighbor test route-map pve_bgp_test_in in
+ 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
+!
+route-map pve_bgp permit 65535
+exit
+!
+route-map pve_bgp_test_in permit 10
+ match ip address pve_bgp_test_ips
+exit
+!
+ip protocol bgp route-map pve_bgp
-- 
2.47.3





  reply	other threads:[~2026-05-13 18:43 UTC|newest]

Thread overview: 10+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-05-13 18:42 [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v5 0/8] sdn: add BGP fabric Hannes Laimer
2026-05-13 18:42 ` Hannes Laimer [this message]
2026-05-13 18:42 ` [PATCH proxmox-perl-rs v5 2/8] sdn: fabrics: add BGP config generation Hannes Laimer
2026-05-13 18:42 ` [PATCH proxmox-perl-rs v5 3/8] sdn: fabrics: add BGP status endpoints Hannes Laimer
2026-05-13 18:42 ` [PATCH pve-network v5 4/8] sdn: fabrics: register bgp as a fabric protocol type Hannes Laimer
2026-05-13 18:42 ` [PATCH pve-network v5 5/8] sdn: evpn: support eBGP VTEPs over BGP fabric underlays Hannes Laimer
2026-05-13 18:42 ` [PATCH pve-network v5 6/8] test: evpn: add integration test for EVPN over BGP fabric Hannes Laimer
2026-05-13 18:42 ` [PATCH pve-manager v5 7/8] ui: sdn: add BGP fabric support Hannes Laimer
2026-05-13 18:42 ` [PATCH pve-docs v5 8/8] sdn: add bgp fabric section Hannes Laimer
2026-05-15 10:26 ` superseded: [PATCH docs/manager/network/proxmox{-ve-rs,-perl-rs} v5 0/8] sdn: add BGP fabric 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=20260513184213.506775-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal