[PATCH proxmox-ve-rs v5 13/46] ve-config: add route map section config

Stefan Hanreich
2026-05-05T15:42:18Z

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 <s.hanreich@proxmox.com>
---
proxmox-ve-config/src/sdn/mod.rs | 1 +
proxmox-ve-config/src/sdn/route_map.rs | 581 +++++++++++++++++++++++++
2 files changed, 582 insertions(+)
create mode 100644 proxmox-ve-config/src/sdn/route_map.rs

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..211f02f
--- /dev/null
+++ b/proxmox-ve-config/src/sdn/route_map.rs
@@ -0,0 +1,581 @@
+//! 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 looks like this:
+//!
+//! ```text
+//! route-map test permit 10
+//! match ip next-hop address 192.0.2.1
+//! set local-preference 200
+//! on-match goto 1234
+//!
+//! route-map test permit 20
+//! call some-other-routemap
+//! ```
+//!
+//! 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
+//! exit-action key=on-match-goto,value=1234
+//!
+//! route-map-entry: test_20
+//! call some-other-routemap
+//! ```
+//!
+//! Match / Set Actions and Exit Policies 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.
+
+use std::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},
+ ModifyNumber, 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: u16,
+}
+
+impl RouteMapEntryId {
+ /// Create a new Route Map Entry ID.
+ pub fn new(route_map_id: RouteMapId, order: u16) -> 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) -> u16 {
+ 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<Self, Self::Err> {
+ 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,
+}
+
+/// The exit action for a route map.
+///
+/// This can be optionally specified to override the default behavior of FRR to terminate
+/// evaluating the route map if an entry matches.
+#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(tag = "key", content = "value", rename_all = "kebab-case")]
+pub enum ExitAction {
+ OnMatchNext,
+ OnMatchGoto(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u16")] u16),
+ Continue(#[serde(deserialize_with = "proxmox_serde::perl::deserialize_u16")] u16),
+}
+
+impl ApiType for ExitAction {
+ const API_SCHEMA: Schema = ObjectSchema::new(
+ "Exit action for a FRR route map.",
+ &[
+ (
+ "key",
+ false,
+ &StringSchema::new("The key indicating which value should be set.")
+ .format(&ApiStringFormat::Enum(&[
+ EnumEntry::new(
+ "on-match-next",
+ "Proceed with the next entry in the route map.",
+ ),
+ EnumEntry::new("continue", "Continue with route map entry with order <value>."),
+ EnumEntry::new(
+ "on-match-goto",
+ "Continue processing the route map with the first entry with order >= <value>.",
+ ),
+ ]))
+ .schema(),
+ ),
+ (
+ "value",
+ true,
+ &StringSchema::new("The value that should be set - depends on the given key.")
+ .schema(),
+ ),
+ ],
+ )
+ .schema();
+}
+
+impl UpdaterType for ExitAction {
+ type Updater = Option<ExitAction>;
+}
+
+#[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),
+ }
+ },
+ "exit-action": {
+ type: String,
+ description: "Exit action for the route map.",
+ optional: true,
+ format: &ApiStringFormat::PropertyString(&ExitAction::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.
+#[serde(rename_all = "kebab-case")]
+pub struct RouteMapEntry {
+ id: RouteMapEntryId,
+ action: RouteMapAction,
+ #[serde(default, rename = "set")]
+ set_actions: Vec<PropertyString<SetAction>>,
+ #[serde(default, rename = "match")]
+ match_actions: Vec<PropertyString<MatchAction>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ call: Option<RouteMapId>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ exit_action: Option<PropertyString<ExitAction>>,
+}
+
+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<Item = PropertyString<SetAction>>,
+ ) {
+ 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<Item = PropertyString<MatchAction>>,
+ ) {
+ self.match_actions = match_actions.into_iter().collect();
+ }
+
+ pub fn set_call(&mut self, call: Option<RouteMapId>) {
+ self.call = call;
+ }
+
+ pub fn set_exit_action(&mut self, exit_action: Option<PropertyString<ExitAction>>) {
+ self.exit_action = exit_action;
+ }
+}
+
+#[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(ModifyNumber),
+ 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),
+ }
+ },
+ "exit-action": {
+ type: String,
+ description: "Exit action for the route map.",
+ optional: true,
+ format: &ApiStringFormat::PropertyString(&ExitAction::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_u16")]
+ pub order: u16,
+ pub action: RouteMapAction,
+ #[serde(default, rename = "set")]
+ pub set_actions: Vec<PropertyString<SetAction>>,
+ #[serde(default, rename = "match")]
+ pub match_actions: Vec<PropertyString<MatchAction>>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub call: Option<RouteMapId>,
+ #[serde(default, skip_serializing_if = "Option::is_none")]
+ pub exit_action: Option<PropertyString<ExitAction>>,
+ }
+
+ 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) -> u16 {
+ self.order
+ }
+ }
+
+ #[derive(Debug, Clone, Serialize, Deserialize, Hash)]
+ #[serde(rename_all = "kebab-case")]
+ /// Deletable properties for Route Map entries.
+ pub enum RouteMapDeletableProperties {
+ Set,
+ Match,
+ Call,
+ ExitAction,
+ }
+
+ impl From<super::RouteMapEntry> 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,
+ call: value.call,
+ exit_action: value.exit_action,
+ }
+ }
+ }
+
+ impl From<RouteMapEntry> 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,
+ call: value.call,
+ exit_action: value.exit_action,
+ }
+ }
+ }
+}
+
+#[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
+ call some-other-route-map
+ exit-action key=on-match-goto,value=1234
+"#;
+
+ RouteMap::parse_section_config("route-maps.cfg", section_config)?;
+ Ok(())
+ }
+}
--
2.47.3