From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 8E8F31FF13B for ; Wed, 25 Mar 2026 10:49:31 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EEAE411246; Wed, 25 Mar 2026 10:49:42 +0100 (CET) From: Stefan Hanreich To: pve-devel@lists.proxmox.com Subject: [PATCH proxmox-ve-rs 7/9] ve-config: add route map section config Date: Wed, 25 Mar 2026 10:41:20 +0100 Message-ID: <20260325094142.174364-10-s.hanreich@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260325094142.174364-1-s.hanreich@proxmox.com> References: <20260325094142.174364-1-s.hanreich@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1774431663775 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.714 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_VALIDITY_CERTIFIED_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_RPBL_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. RCVD_IN_VALIDITY_SAFE_BLOCKED 0.001 ADMINISTRATOR NOTICE: The query to Validity was blocked. See https://knowledge.validity.com/hc/en-us/articles/20961730681243 for more information. SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: XPRDF722TB7Q6YB4FRC3Y5JGS34RFTQS X-Message-ID-Hash: XPRDF722TB7Q6YB4FRC3Y5JGS34RFTQS X-MailFrom: s.hanreich@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Proxmox VE development discussion List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: Those types represent FRR route maps inside a section config format. For an example of the exact format and its FRR representation see the module-level documentation. One section config entry maps to one route map entry. A route map consists of one or more route map entries inside the section config. The ID of a section encodes the name of the route map as well as the order # of the entry. The route map module exports specific types for the API that handle converting the section config ID, because currently it is only possible to deserialize section config IDs to Strings. To avoid having to implement the parsing logic along every step of the stack (Perl backend, UI), use specific API types in the public API that handle parsing the section ID into route map name and order. Contrary to most SDN entities, route maps IDs can be 32 characters long instead of 8 and support underscores as well as hyphens. This is because the restriction of having to generate network interface names does not apply to FRR entities, so we can be more lenient with IDs here, allowing users to specify more descriptive names. Signed-off-by: Stefan Hanreich --- proxmox-ve-config/debian/control | 2 + proxmox-ve-config/src/sdn/mod.rs | 1 + proxmox-ve-config/src/sdn/route_map.rs | 491 +++++++++++++++++++++++++ 3 files changed, 494 insertions(+) create mode 100644 proxmox-ve-config/src/sdn/route_map.rs diff --git a/proxmox-ve-config/debian/control b/proxmox-ve-config/debian/control index 440cf73..5206340 100644 --- a/proxmox-ve-config/debian/control +++ b/proxmox-ve-config/debian/control @@ -24,6 +24,7 @@ Build-Depends-Arch: cargo:native , librust-serde-1+default-dev , librust-serde-1+derive-dev , librust-serde-json-1+default-dev , + librust-serde-with-3+default-dev , librust-thiserror-2+default-dev , librust-tracing-0.1+default-dev (>= 0.1.37-~~) Maintainer: Proxmox Support Team @@ -55,6 +56,7 @@ Depends: librust-serde-1+default-dev, librust-serde-1+derive-dev, librust-serde-json-1+default-dev, + librust-serde-with-3+default-dev, librust-thiserror-2+default-dev, librust-tracing-0.1+default-dev (>= 0.1.37-~~) Suggests: diff --git a/proxmox-ve-config/src/sdn/mod.rs b/proxmox-ve-config/src/sdn/mod.rs index 344c02c..24069ad 100644 --- a/proxmox-ve-config/src/sdn/mod.rs +++ b/proxmox-ve-config/src/sdn/mod.rs @@ -2,6 +2,7 @@ pub mod config; pub mod fabric; pub mod ipam; pub mod prefix_list; +pub mod route_map; use std::{error::Error, fmt::Display, str::FromStr}; diff --git a/proxmox-ve-config/src/sdn/route_map.rs b/proxmox-ve-config/src/sdn/route_map.rs new file mode 100644 index 0000000..3f4da56 --- /dev/null +++ b/proxmox-ve-config/src/sdn/route_map.rs @@ -0,0 +1,491 @@ +//! Section config types for FRR Route Maps. +//! +//! This module contains the API types required for representing FRR Route Maps as section config. +//! Each entry in the section config maps to a Route Map entry, *not* a route map as a whole, the +//! order of the entry is encoded in the ID of the Route Map. +//! +//! Route maps in FRR consists of at least one entry, which are ordered by their given sequence +//! number / order. Each entry has a default matching policy, which is applied if the matching +//! conditions of the entry are met. +//! +//! An example for a simple FRR Route Map entry loooks like this: +//! +//! ```text +//! route-map test permit 10 +//! match ip next-hop address 192.0.2.1 +//! set local-preference 200 +//! ``` +//! +//! The corresponding representation as a section config entry looks like this: +//! +//! ```text +//! route-map-entry: test_10 +//! action permit +//! match key=ip-next-hop-address,value=192.0.2.1 +//! set key=local-preference,value=200 +//! ``` +//! +//! Match and Set Actions are encoded as an array with a property string that has a key and an +//! optional value paramter, because some options do not require an additional value. +//! +//! This abstraction currently supports Match and Set actions, but not call actions and exit +//! actions. + +use core::net::IpAddr; + +use anyhow::format_err; +use const_format::concatcp; + +use proxmox_network_types::ip_address::api_types::{Ipv4Addr, Ipv6Addr}; +use proxmox_sdn_types::{ + bgp::{EvpnRouteType, SetMetricValue, SetTagValue}, + IntegerWithSign, Vni, +}; +use serde::{Deserialize, Serialize}; + +use proxmox_schema::{ + api, api_string_type, const_regex, property_string::PropertyString, ApiStringFormat, ApiType, + EnumEntry, ObjectSchema, Schema, StringSchema, Updater, UpdaterType, +}; + +use crate::sdn::prefix_list::PrefixListId; + +pub const ROUTE_MAP_ID_REGEX_STR: &str = + r"(?:[a-zA-Z0-9](?:[a-zA-Z0-9\-_]){0,30}(?:[a-zA-Z0-9]){0,1})"; + +pub const ROUTE_MAP_ORDER_REGEX_STR: &str = r"\d+"; + +const_regex! { + pub ROUTE_MAP_ID_REGEX = concatcp!(r"^", ROUTE_MAP_ID_REGEX_STR, r"$"); + pub ROUTE_MAP_SECTION_ID_REGEX = concatcp!(r"^", ROUTE_MAP_ID_REGEX_STR, r"_", ROUTE_MAP_ORDER_REGEX_STR, r"$"); +} + +pub const ROUTE_MAP_SECTION_ID_FORMAT: ApiStringFormat = + ApiStringFormat::Pattern(&ROUTE_MAP_SECTION_ID_REGEX); + +pub const ROUTE_MAP_ID_FORMAT: ApiStringFormat = ApiStringFormat::Pattern(&ROUTE_MAP_ID_REGEX); + +api_string_type! { + /// ID of a Route Map.. + #[api(format: &ROUTE_MAP_ID_FORMAT)] + #[derive(Debug, Deserialize, Serialize, Clone, Hash, PartialEq, Eq, PartialOrd, Ord, UpdaterType)] + pub struct RouteMapId(String); +} + +/// The ID of a Route Map entry in the section config (name + order). +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct RouteMapEntryId { + /// name of the Route Map + route_map_id: RouteMapId, + /// seq nr of the Route Map + order: u32, +} + +impl RouteMapEntryId { + /// Create a new Route Map Entry ID. + pub fn new(route_map_id: RouteMapId, order: u32) -> Self { + Self { + route_map_id, + order, + } + } + + /// Returns the name part of the Route Map section id. + pub fn route_map_id(&self) -> &RouteMapId { + &self.route_map_id + } + + /// Returns the order part of the Route Map section id. + pub fn order(&self) -> u32 { + self.order + } +} + +impl ApiType for RouteMapEntryId { + const API_SCHEMA: Schema = StringSchema::new("ID of a SDN node in the section config") + .format(&ROUTE_MAP_SECTION_ID_FORMAT) + .schema(); +} + +impl std::fmt::Display for RouteMapEntryId { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(f, "{}_{}", self.route_map_id, self.order) + } +} + +proxmox_serde::forward_serialize_to_display!(RouteMapEntryId); + +impl std::str::FromStr for RouteMapEntryId { + type Err = anyhow::Error; + + fn from_str(value: &str) -> Result { + let (name, order) = value + .rsplit_once("_") + .ok_or_else(|| format_err!("invalid RouteMap section id: {}", value))?; + + Ok(Self { + route_map_id: RouteMapId::from_string(name.to_string())?, + order: order.parse()?, + }) + } +} + +proxmox_serde::forward_deserialize_to_from_str!(RouteMapEntryId); + +#[api( + "id-property": "id", + "id-schema": { + type: String, + description: "Route Map Section ID", + format: &ROUTE_MAP_SECTION_ID_FORMAT, + }, + "type-key": "type", +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "type")] +/// The Route Map section config type. +pub enum RouteMap { + RouteMapEntry(RouteMapEntry), +} + +#[api()] +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +/// Matching policy of a Route Map entry. +pub enum RouteMapAction { + /// Permit + Permit, + /// Deny + Deny, +} + +#[api( + properties: { + set: { + type: Array, + description: "A list of Set actions to perform in this entry.", + optional: true, + items: { + type: String, + description: "A specific Set action.", + format: &ApiStringFormat::PropertyString(&SetAction::API_SCHEMA), + } + }, + "match": { + type: Array, + description: "A list of Match actions to perform in this entry.", + optional: true, + items: { + type: String, + description: "A specific match action.", + format: &ApiStringFormat::PropertyString(&MatchAction::API_SCHEMA), + } + }, + } +)] +#[derive(Debug, Clone, Serialize, Deserialize)] +/// Route Map Entry +/// +/// Represents one entry in a Route Map. One Route Map is made up of one or more entries, that are +/// executed in order of their ordering number. +pub struct RouteMapEntry { + id: RouteMapEntryId, + action: RouteMapAction, + #[serde(default, rename = "set")] + set_actions: Vec>, + #[serde(default, rename = "match")] + match_actions: Vec>, +} + +impl RouteMapEntry { + /// Return the ID of the Route Map. + pub fn id(&self) -> &RouteMapEntryId { + &self.id + } + + /// Sets the action for this entry. + pub fn set_action(&mut self, action: RouteMapAction) { + self.action = action; + } + + /// Set the set actions for this route map entry. + pub fn set_set_actions( + &mut self, + set_actions: impl IntoIterator>, + ) { + self.set_actions = set_actions.into_iter().collect(); + } + + /// Set the match actions for this route map entry. + pub fn set_match_actions( + &mut self, + match_actions: impl IntoIterator>, + ) { + self.match_actions = match_actions.into_iter().collect(); + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "key", content = "value")] +/// A Route Map set action +pub enum SetAction { + IpNextHopPeerAddress, + IpNextHopUnchanged, + IpNextHop(Ipv4Addr), + Ip6NextHopPeerAddress, + Ip6NextHopPreferGlobal, + Ip6NextHop(Ipv6Addr), + Weight(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] u32), + Tag(SetTagValue), + Metric(SetMetricValue), + LocalPreference(IntegerWithSign), + Src(IpAddr), +} + +impl ApiType for SetAction { + const API_SCHEMA: Schema = ObjectSchema::new( + "FRR set action", + &[ + ( + "key", + false, + &StringSchema::new("The key indicating which value should be set.") + .format(&ApiStringFormat::Enum(&[ + EnumEntry::new( + "ip-next-hop-peer-address", + "Sets the BGP nexthop address to the IPv4 peer address.", + ), + EnumEntry::new("ip-next-hop-unchanged", "Leaves the nexthop unchanged."), + EnumEntry::new( + "ip-next-hop", + "Sets the nexthop to the given IPv4 address.", + ), + EnumEntry::new( + "ip6-next-hop-peer-address", + "Sets the BGP nexthop address to the IPv6 peer address.", + ), + EnumEntry::new( + "ip6-next-hop-prefer-global", + "If a LLA and GUA are received, prefer the GUA.", + ), + EnumEntry::new( + "ip6-next-hop", + "Sets the nexthop to the given IPv6 address.", + ), + EnumEntry::new( + "local-preference", + "Sets the local preference for this route.", + ), + EnumEntry::new("tag", "Sets a tag for the route."), + EnumEntry::new("weight", "Sets the weight for the route."), + EnumEntry::new("metric", "Sets the metric for the route."), + EnumEntry::new( + "src", + "The source address to insert into the kernel routing table.", + ), + ])) + .schema(), + ), + ( + "value", + true, + &StringSchema::new("The value that should be set - depends on the given key.") + .schema(), + ), + ], + ) + .schema(); +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case", tag = "key", content = "value")] +pub enum MatchAction { + RouteType(EvpnRouteType), + Vni(Vni), + IpAddressPrefixList(PrefixListId), + Ip6AddressPrefixList(PrefixListId), + IpNextHopPrefixList(PrefixListId), + Ip6NextHopPrefixList(PrefixListId), + IpNextHopAddress(Ipv4Addr), + Ip6NextHopAddress(Ipv6Addr), + Tag(SetTagValue), + Metric(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] u32), + LocalPreference(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] u32), + Peer(String), +} + +impl ApiType for MatchAction { + const API_SCHEMA: Schema = ObjectSchema::new( + "FRR set action", + &[ + ( + "key", + false, + &StringSchema::new("The key indicating on which value to match.") + .format(&ApiStringFormat::Enum(&[ + EnumEntry::new("route-type", "Match the EVPN route type."), + EnumEntry::new("vni", "Match the VNI of an EVPN route."), + EnumEntry::new( + "ip-address-prefix-list", + "Match the IPv4 CIDR to a prefix-list.", + ), + EnumEntry::new( + "ip6-address-prefix-list", + "Match the IPv6 CIDR to a prefix-list", + ), + EnumEntry::new( + "ip-next-hop-prefix-list", + "Match the IPv4 next-hop to a prefix-list.", + ), + EnumEntry::new( + "ip6-next-hop-prefix-list", + "Match the IPv4 next-hop to a prefix-list.", + ), + EnumEntry::new( + "ip-next-hop-address", + "Match the next-hop to an IPv4 address.", + ), + EnumEntry::new( + "ip6-next-hop-address", + "Match the next-hop to an IPv6 address.", + ), + EnumEntry::new("metric", "Match the metric of the route."), + EnumEntry::new("local-preference", "Match the local preference."), + EnumEntry::new( + "peer", + "Match the peer IP address, interface name or peer group.", + ), + ])) + .schema(), + ), + ( + "value", + true, + &StringSchema::new("The value that should be matched - depends on the given key.") + .schema(), + ), + ], + ) + .schema(); +} + +pub mod api { + //! API type for Route Map Entries. + //! + //! Since Route Map Entries encode information in their ID, these types help converting to / + //! from the Section Config types. + use super::*; + + #[api( + properties: { + set: { + type: Array, + description: "A list of set actions for this Route Map entry", + optional: true, + items: { + type: String, + description: "A set action", + format: &ApiStringFormat::PropertyString(&SetAction::API_SCHEMA), + } + }, + "match": { + type: Array, + description: "A list of match actions for this Route Map entry", + optional: true, + items: { + type: String, + description: "A match action", + format: &ApiStringFormat::PropertyString(&MatchAction::API_SCHEMA), + } + }, + } + )] + #[derive(Debug, Clone, Serialize, Deserialize, Updater)] + #[serde(rename_all = "kebab-case")] + /// Route Map entry + pub struct RouteMapEntry { + /// name of the Route Map + #[updater(skip)] + pub route_map_id: RouteMapId, + /// seq nr of the Route Map + #[updater(skip)] + #[serde(deserialize_with = "proxmox_serde::perl::deserialize_u32")] + pub order: u32, + pub action: RouteMapAction, + #[serde(default, rename = "set")] + pub set_actions: Vec>, + #[serde(default, rename = "match")] + pub match_actions: Vec>, + } + + impl RouteMapEntry { + /// Return the ID of the Route Map this entry belongs to. + pub fn route_map_id(&self) -> &RouteMapId { + &self.route_map_id + } + + /// Return the order for this Route Map entry. + pub fn order(&self) -> u32 { + self.order + } + } + + #[derive(Debug, Clone, Serialize, Deserialize, Hash)] + #[serde(rename_all = "kebab-case")] + /// Deletable properties for Route Map entries. + pub enum RouteMapDeletableProperties { + SetActions, + MatchActions, + } + + impl From for RouteMapEntry { + fn from(value: super::RouteMapEntry) -> RouteMapEntry { + RouteMapEntry { + route_map_id: value.id.route_map_id, + order: value.id.order, + action: value.action, + set_actions: value.set_actions, + match_actions: value.match_actions, + } + } + } + + impl From for super::RouteMapEntry { + fn from(value: RouteMapEntry) -> super::RouteMapEntry { + super::RouteMapEntry { + id: RouteMapEntryId { + route_map_id: value.route_map_id, + order: value.order, + }, + action: value.action, + set_actions: value.set_actions, + match_actions: value.match_actions, + } + } + } +} + +#[cfg(test)] +mod tests { + use proxmox_section_config::typed::ApiSectionDataEntry; + + use super::*; + + #[test] + fn test_simple_route_map() -> Result<(), anyhow::Error> { + let section_config = r#" +route-map-entry: test_underscore_123 + action permit + set key=tag,value=23487 + set key=tag,value=untagged + set key=metric,value=+rtt + set key=local-preference,value=-12345 + set key=ip-next-hop,value=192.0.2.0 + match key=vni,value=23487 + match key=vni,value=23487 +"#; + + RouteMap::parse_section_config("route-maps.cfg", section_config)?; + Ok(()) + } +} -- 2.47.3