public inbox for pve-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Hannes Laimer <h.laimer@proxmox.com>
To: pve-devel@lists.proxmox.com
Subject: [PATCH proxmox-ve-rs v3 09/45] ve-config: sdn: microseg: narrow rules by protocol and ports
Date: Tue, 27 Oct 2026 09:54:51 +0100	[thread overview]
Message-ID: <20261027085527.702738-10-h.laimer@proxmox.com> (raw)
In-Reply-To: <20261027085527.702738-1-h.laimer@proxmox.com>

A rule can be narrowed to a protocol, optionally with ports or an
ICMP type, or to a firewall macro standing in for them. It then
decides only the traffic it matches.

For a pair, the highest priority with a rule for the whole pair gives
the verdict. Narrowed rules down to that priority are checked first,
in priority order. So a narrowed rule makes an exception to a
whole-pair rule of the same priority. The model also reports each
rule's place in the evaluation order, so a caller that lists the
rules does not have to work the order out again.

What a rule narrows to is part of its id, so two rules on the same
predicates can coexist. Like the predicates, it cannot be changed
after creation. A both-ways rule matches the reverse direction with
the ports swapped. It cannot be narrowed to an ICMP type, since the
reverse type cannot be derived in general.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 proxmox-ve-config/src/firewall/fw_macros.rs |   7 +
 proxmox-ve-config/src/firewall/ports.rs     |  65 +++++
 proxmox-ve-config/src/sdn/microseg/api.rs   |  64 ++++-
 proxmox-ve-config/src/sdn/microseg/mod.rs   | 266 ++++++++++++++++++--
 proxmox-ve-config/src/sdn/microseg/types.rs | 116 ++++++++-
 5 files changed, 496 insertions(+), 22 deletions(-)

diff --git a/proxmox-ve-config/src/firewall/fw_macros.rs b/proxmox-ve-config/src/firewall/fw_macros.rs
index 5fa8dab1ca26..e0bdf365635b 100644
--- a/proxmox-ve-config/src/firewall/fw_macros.rs
+++ b/proxmox-ve-config/src/firewall/fw_macros.rs
@@ -67,3 +67,10 @@ fn macros() -> &'static HashMap<String, FwMacro> {
 pub fn get_macro(name: &str) -> Option<&'static FwMacro> {
     macros().get(name)
 }
+
+pub fn macro_name(name: &str) -> Option<&'static str> {
+    macros()
+        .keys()
+        .find(|known| known.eq_ignore_ascii_case(name))
+        .map(String::as_str)
+}
diff --git a/proxmox-ve-config/src/firewall/ports.rs b/proxmox-ve-config/src/firewall/ports.rs
index f88a442adab4..62d3bacda228 100644
--- a/proxmox-ve-config/src/firewall/ports.rs
+++ b/proxmox-ve-config/src/firewall/ports.rs
@@ -78,3 +78,68 @@ pub fn parse_named_port(name: &str) -> Result<u16, Error> {
         .find(name)
         .ok_or_else(|| format_err!("unknown port name {name:?}"))
 }
+
+#[derive(Default)]
+struct NamedProtocols {
+    protocols: std::collections::HashMap<String, u8>,
+}
+
+impl NamedProtocols {
+    fn new() -> Self {
+        use std::io::BufRead;
+
+        log::trace!("loading /etc/protocols");
+
+        let mut this = Self::default();
+
+        let file = match std::fs::File::open("/etc/protocols") {
+            Ok(file) => file,
+            Err(_) => return this,
+        };
+
+        for line in std::io::BufReader::new(file).lines() {
+            let line = match line {
+                Ok(line) => line,
+                Err(_) => break,
+            };
+
+            let line = line.split_once('#').map_or(line.as_str(), |(text, _)| text);
+
+            let mut parts = line.split_ascii_whitespace();
+
+            let (name, number) = match (parts.next(), parts.next()) {
+                (Some(name), Some(number)) => (name, number),
+                _ => continue,
+            };
+
+            let number: u8 = match number.parse() {
+                Ok(number) => number,
+                Err(_) => continue,
+            };
+
+            // the first line naming a protocol wins, as for getprotobyname
+            for name in std::iter::once(name).chain(parts) {
+                this.protocols.entry(name.to_string()).or_insert(number);
+            }
+        }
+
+        this
+    }
+
+    fn find(&self, name: &str) -> Option<u8> {
+        self.protocols.get(name).copied()
+    }
+}
+
+fn named_protocols() -> &'static NamedProtocols {
+    static NAMED_PROTOCOLS: OnceLock<NamedProtocols> = OnceLock::new();
+
+    NAMED_PROTOCOLS.get_or_init(NamedProtocols::new)
+}
+
+/// Parse a named protocol with the help of `/etc/protocols`.
+pub fn parse_named_protocol(name: &str) -> Result<u8, Error> {
+    named_protocols()
+        .find(name)
+        .ok_or_else(|| format_err!("unknown protocol name {name:?}"))
+}
diff --git a/proxmox-ve-config/src/sdn/microseg/api.rs b/proxmox-ve-config/src/sdn/microseg/api.rs
index 08b5ee1f4653..fe125c4d4c1b 100644
--- a/proxmox-ve-config/src/sdn/microseg/api.rs
+++ b/proxmox-ve-config/src/sdn/microseg/api.rs
@@ -8,6 +8,8 @@ use anyhow::{Error, bail};
 use proxmox_section_config::typed::ApiSectionDataEntry;
 use serde::Deserialize;
 
+use crate::firewall::fw_macros;
+
 use super::types::{
     Binding, GroupSection, GuestAssignmentSection, GuestInfo, MicrosegEntry, MicrosegId,
     MicrosegRunningConfig, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection,
@@ -52,6 +54,16 @@ pub struct RuleCreate {
     #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
     pub allow: bool,
     #[serde(default)]
+    pub proto: Option<String>,
+    #[serde(default)]
+    pub sport: Option<String>,
+    #[serde(default)]
+    pub dport: Option<String>,
+    #[serde(default, rename = "icmp-type")]
+    pub icmp_type: Option<String>,
+    #[serde(default, rename = "macro")]
+    pub fw_macro: Option<String>,
+    #[serde(default)]
     pub comment: Option<String>,
 }
 
@@ -195,6 +207,7 @@ fn rule_id(
     src_groups: &[&str],
     dst_match: PredicateMatch,
     dst_groups: &[&str],
+    narrowing: &[Option<&str>],
     bidirectional: bool,
 ) -> Result<MicrosegId, Error> {
     let mut hash = Fnv1a::new();
@@ -202,6 +215,9 @@ fn rule_id(
     hash.groups(src_groups);
     hash.match_kind(dst_match);
     hash.groups(dst_groups);
+    for field in narrowing {
+        hash.text(field.unwrap_or_default());
+    }
     hash.flag(bidirectional);
     format!("r{:016x}", hash.0).parse()
 }
@@ -289,11 +305,30 @@ fn build_entry(
         MicrosegCreate::Rule(rule) => {
             let src_groups = sorted_groups(&rule.src, entries)?;
             let dst_groups = sorted_groups(&rule.dst, entries)?;
+            let trimmed = |field: Option<String>| field.map(|value| value.trim().to_string());
+            let proto = trimmed(rule.proto);
+            let sport = trimmed(rule.sport);
+            let dport = trimmed(rule.dport);
+            let icmp_type = trimmed(rule.icmp_type);
+            // the expansion looks a macro up by its exact name, so the table's spelling is
+            // stored and an unknown name stays as given for validation to refuse
+            let fw_macro = trimmed(rule.fw_macro).map(|name| match fw_macros::macro_name(&name) {
+                Some(known) => known.to_string(),
+                None => name,
+            });
+            let narrowing = [
+                proto.as_deref(),
+                sport.as_deref(),
+                dport.as_deref(),
+                icmp_type.as_deref(),
+                fw_macro.as_deref(),
+            ];
             let id = rule_id(
                 rule.src_match,
                 &src_groups,
                 rule.dst_match,
                 &dst_groups,
+                &narrowing,
                 rule.bidirectional,
             )?;
             MicrosegEntry::Rule(RuleSection {
@@ -305,6 +340,11 @@ fn build_entry(
                 bidirectional: rule.bidirectional,
                 prio: rule.prio,
                 allow: rule.allow,
+                proto,
+                sport,
+                dport,
+                icmp_type,
+                fw_macro,
                 comment: rule.comment,
             })
         }
@@ -540,13 +580,33 @@ fn predicate_str(match_kind: PredicateMatch, groups: &[impl AsRef<str>]) -> Stri
     format!("{kind}{{{list}}}")
 }
 
+/// Rendered from the stored fields, since expanding a macro can fail and its name is what a user
+/// recognizes.
+fn narrowing_str(rule: &RuleSection) -> Option<String> {
+    if let Some(name) = &rule.fw_macro {
+        return Some(format!("macro {name}"));
+    }
+    let parts = [
+        rule.proto.clone(),
+        rule.sport.as_ref().map(|ports| format!("sport {ports}")),
+        rule.dport.as_ref().map(|ports| format!("dport {ports}")),
+        rule.icmp_type.as_ref().map(|kind| format!("type {kind}")),
+    ];
+    let narrowing = parts.into_iter().flatten().collect::<Vec<_>>().join(" ");
+    (!narrowing.is_empty()).then_some(narrowing)
+}
+
 fn describe_rule(rule: &RuleSection) -> String {
     let arrow = if rule.bidirectional { "<->" } else { "->" };
-    format!(
+    let base = format!(
         "rule {} {arrow} {}",
         predicate_str(rule.src_match, &rule.src),
         predicate_str(rule.dst_match, &rule.dst),
-    )
+    );
+    match narrowing_str(rule) {
+        Some(narrowing) => format!("{base} ({narrowing})"),
+        None => base,
+    }
 }
 
 /// A human-readable description of an entry for diagnostics, by what a user would recognize rather
diff --git a/proxmox-ve-config/src/sdn/microseg/mod.rs b/proxmox-ve-config/src/sdn/microseg/mod.rs
index 620f342ad2b0..3aaf299f8169 100644
--- a/proxmox-ve-config/src/sdn/microseg/mod.rs
+++ b/proxmox-ve-config/src/sdn/microseg/mod.rs
@@ -1,12 +1,12 @@
 //! Microseg subsystem of the SDN config.
 //!
 //! Microseg is an identity-based stateless firewall. Each managed guest NIC gets the *set* of
-//! *groups* of the first *assignment* in admin order that covers it. The render allocates a wire
+//! *groups* of the first *assignment* in match order that covers it. The render allocates a wire
 //! identity for the set of every assignment (see [`identity`]), and the running config carries it
 //! to every host, so a NIC can only land in a class that already exists. Each host looks the
-//! identities up and compiles the policy *rules* into a `(src_id, dst_id) -> allow|deny` map. A
-//! rule matches traffic by a `src` and a `dst` *predicate*, each a match kind over a list of
-//! groups.
+//! identities up and folds the policy *rules* per `(src_id, dst_id)` pair. The fold yields the
+//! narrowed rules to check and the verdict for the traffic none of them matches. A rule matches
+//! traffic by a `src` and a `dst` *predicate*, each a match kind over a list of groups.
 
 pub mod api;
 pub mod identity;
@@ -18,6 +18,7 @@ pub use types::{
     MicrosegRunningConfig, RegexAssignmentSection, RuleSection, Tag, TagAssignmentSection,
 };
 
+use std::cmp::Ordering;
 use std::collections::{BTreeMap, BTreeSet, HashMap};
 
 use anyhow::{Context, Error, bail};
@@ -25,6 +26,8 @@ use identity::UNTAGGED_GROUP;
 use regex::Regex;
 use serde::Serialize;
 
+use crate::firewall::types::rule_match::{IcmpType, Icmpv6Type, Ports, Protocol, Sctp, Tcp, Udp};
+
 /// Validate a microseg config before anything renders from it, so resolving group names and
 /// expanding rules cannot fail further down.
 pub fn validate(entries: &BTreeMap<String, MicrosegEntry>) -> Result<(), Error> {
@@ -86,6 +89,14 @@ fn validate_entry(
     Ok(())
 }
 
+fn has_icmp_type(protocol: &Protocol) -> bool {
+    match protocol {
+        Protocol::Icmp(icmp) => !matches!(icmp.ty(), None | Some(IcmpType::Any)),
+        Protocol::Icmpv6(icmp) => !matches!(icmp.ty(), None | Some(Icmpv6Type::Any)),
+        _ => false,
+    }
+}
+
 fn validate_rule(
     entries: &BTreeMap<String, MicrosegEntry>,
     subject: &str,
@@ -133,6 +144,18 @@ fn validate_rule(
     for group in rule.src.iter().chain(&rule.dst) {
         known_group(entries, group.as_ref()).with_context(|| subject.to_string())?;
     }
+    let protocols = match rule.protocols() {
+        Ok(protocols) => protocols,
+        Err(err) => bail!("{subject}: {err}"),
+    };
+    // a macro can expand to an entry with an ICMP type, so the check reads the expanded
+    // matches and not the rule's own field
+    if rule.bidirectional && protocols.iter().any(has_icmp_type) {
+        bail!(
+            "{subject} is bidirectional and names an ICMP type, the mirror does not \
+             derive a reverse type"
+        );
+    }
     Ok(())
 }
 
@@ -405,6 +428,31 @@ pub fn render(
     })
 }
 
+fn evaluation_order(a: &ResolvedRule, b: &ResolvedRule) -> Ordering {
+    b.prio
+        .cmp(&a.prio)
+        .then(b.is_narrowed().cmp(&a.is_narrowed()))
+        .then(a.allow.cmp(&b.allow))
+        // the id keeps the order stable, and the orientation after it, since both orientations of
+        // a bidirectional rule carry the same id
+        .then(a.id.cmp(&b.id))
+        .then(a.reverse.cmp(&b.reverse))
+}
+
+/// Each rule's place in the evaluation order, by rule id. The first has the lowest number.
+pub fn rule_order(
+    entries: &BTreeMap<String, MicrosegEntry>,
+) -> Result<HashMap<MicrosegId, usize>, Error> {
+    let mut rules = resolved_rules(entries)?;
+    rules.sort_by(evaluation_order);
+    let mut order = HashMap::new();
+    for rule in rules {
+        let place = order.len();
+        order.entry(rule.id).or_insert(place);
+    }
+    Ok(order)
+}
+
 /// Whether a guest renamed from `old` to `new` enters or leaves the names a regex assignment
 /// matches, decided by the same expressions that resolve the NICs.
 pub fn name_match_changed(
@@ -439,10 +487,35 @@ pub fn assignment_order(entries: &BTreeMap<String, MicrosegEntry>) -> HashMap<St
 
 #[derive(Debug)]
 pub struct ResolvedRule {
+    id: MicrosegId,
     src: identity::Predicate,
     dst: identity::Predicate,
     prio: u16,
     allow: bool,
+    /// The protocol matches narrowing the rule. Empty for a rule deciding the whole pair.
+    protocols: Vec<Protocol>,
+    /// Set on the mirrored orientation of a bidirectional rule.
+    reverse: bool,
+}
+
+impl ResolvedRule {
+    fn is_narrowed(&self) -> bool {
+        !self.protocols.is_empty()
+    }
+}
+
+fn reversed_protocol(protocol: &Protocol) -> Protocol {
+    let swapped = |ports: &Ports| Ports::new(ports.dport().cloned(), ports.sport().cloned());
+    match protocol {
+        Protocol::Dccp(ports) => Protocol::Dccp(swapped(ports)),
+        Protocol::Sctp(sctp) => Protocol::Sctp(Sctp::new(swapped(sctp.ports()))),
+        Protocol::Tcp(tcp) => Protocol::Tcp(Tcp::new(swapped(tcp.ports()))),
+        Protocol::Udp(udp) => Protocol::Udp(Udp::new(swapped(udp.ports()))),
+        Protocol::UdpLite(ports) => Protocol::UdpLite(swapped(ports)),
+        Protocol::Icmp(_) | Protocol::Icmpv6(_) | Protocol::Named(_) | Protocol::Numeric(_) => {
+            protocol.clone()
+        }
+    }
 }
 
 pub fn resolved_rules(
@@ -452,16 +525,22 @@ pub fn resolved_rules(
     for rule in rule_entries(entries) {
         let context = || format!("rule '{}'", rule.id);
         let forward = ResolvedRule {
+            id: rule.id.clone(),
             src: predicate(entries, rule.src_match, &rule.src).with_context(context)?,
             dst: predicate(entries, rule.dst_match, &rule.dst).with_context(context)?,
             prio: rule.prio,
             allow: rule.allow,
+            protocols: rule.protocols().with_context(context)?,
+            reverse: false,
         };
         let mirrored = rule.bidirectional.then(|| ResolvedRule {
+            id: forward.id.clone(),
             src: forward.dst.clone(),
             dst: forward.src.clone(),
             prio: forward.prio,
             allow: forward.allow,
+            protocols: forward.protocols.iter().map(reversed_protocol).collect(),
+            reverse: true,
         });
         resolved.push(forward);
         resolved.extend(mirrored);
@@ -469,26 +548,39 @@ pub fn resolved_rules(
     Ok(resolved)
 }
 
-/// Fold the rules that fire on one `(src_class, dst_class)` pair into its verdict. The highest
-/// priority wins, and a single deny among the rules tied there overrides the allows. Without a
-/// firing rule the pair denies.
-pub fn fold<'a>(firing: impl IntoIterator<Item = &'a ResolvedRule>) -> bool {
-    let mut best: Option<(u16, bool)> = None;
-    for rule in firing {
-        best = Some(match best {
-            Some((prio, _)) if rule.prio > prio => (rule.prio, rule.allow),
-            Some((prio, allow)) if rule.prio == prio => (prio, allow && rule.allow),
-            Some(current) => current,
-            None => (rule.prio, rule.allow),
-        });
+#[derive(Debug)]
+pub struct Folded<'a> {
+    /// Narrowed rules in evaluation order. The first one whose protocol match fires decides.
+    pub checks: Vec<&'a ResolvedRule>,
+    pub allow: bool,
+}
+
+/// Fold the rules that fire on one `(src_class, dst_class)` pair into its verdict and checks. The
+/// verdict comes from the highest priority with a rule that decides the whole pair, and a single
+/// deny among those rules wins. Without such a rule the pair denies. The checks are the narrowed
+/// rules from the top priority down to that one, denies ahead of allows within a priority.
+pub fn fold<'a>(firing: impl IntoIterator<Item = &'a ResolvedRule>) -> Folded<'a> {
+    let mut firing: Vec<&ResolvedRule> = firing.into_iter().collect();
+    firing.sort_by(|a, b| evaluation_order(a, b));
+
+    let mut checks = Vec::new();
+    let mut allow = false;
+    for level in firing.chunk_by(|a, b| a.prio == b.prio) {
+        checks.extend(level.iter().filter(|rule| rule.is_narrowed()));
+        let mut deciding = level.iter().filter(|rule| !rule.is_narrowed()).peekable();
+        if deciding.peek().is_some() {
+            allow = deciding.all(|rule| rule.allow);
+            break;
+        }
     }
-    best.is_some_and(|(_, allow)| allow)
+    Folded { checks, allow }
 }
 
 #[cfg(test)]
 mod tests {
 
     use super::*;
+    use crate::firewall::types::rule_match::RuleOptions;
 
     pub(super) fn firing_rules<'a>(
         rules: &'a [ResolvedRule],
@@ -679,7 +771,9 @@ mod tests {
     }
 
     fn verdict(rules: &[ResolvedRule], src: &identity::GroupSet, dst: &identity::GroupSet) -> bool {
-        fold(firing_rules(rules, src, dst))
+        let folded = fold(firing_rules(rules, src, dst));
+        assert!(folded.checks.is_empty(), "no narrowed rules expected");
+        folded.allow
     }
 
     fn rule_full(
@@ -702,6 +796,11 @@ mod tests {
                 bidirectional: false,
                 prio,
                 allow,
+                proto: None,
+                sport: None,
+                dport: None,
+                icmp_type: None,
+                fw_macro: None,
                 comment: None,
             }),
         )
@@ -742,4 +841,135 @@ mod tests {
         // from an interface in web alone only the allow fires
         assert!(verdict(&tied_rules, &set(&["web"]), &set(&["db"])));
     }
+
+    fn narrowed(
+        name: &str,
+        src: &[&str],
+        dst: &[&str],
+        prio: u16,
+        allow: bool,
+        proto: Option<&str>,
+        dport: Option<&str>,
+    ) -> (String, MicrosegEntry) {
+        let entry = rule_full(
+            name,
+            src,
+            PredicateMatch::Any,
+            dst,
+            PredicateMatch::Any,
+            prio,
+            allow,
+        );
+        with_rule(entry, |rule| {
+            rule.proto = proto.map(str::to_string);
+            rule.dport = dport.map(str::to_string);
+        })
+    }
+
+    #[test]
+    fn cell_orders_checks_and_decides_the_rest() {
+        let entries: BTreeMap<String, MicrosegEntry> = BTreeMap::from([
+            group("web"),
+            group("db"),
+            rule_full(
+                "whole",
+                &["web"],
+                PredicateMatch::Any,
+                &["db"],
+                PredicateMatch::Any,
+                0,
+                true,
+            ),
+            narrowed("ssh", &["web"], &["db"], 10, false, Some("tcp"), Some("22")),
+            narrowed("dns", &["web"], &["db"], 10, true, Some("udp"), Some("53")),
+            narrowed("http", &["web"], &["db"], 20, true, Some("tcp"), Some("80")),
+        ]);
+        let rules = resolved(&entries);
+        let folded = fold(firing_rules(&rules, &set(&["web"]), &set(&["db"])));
+        let order: Vec<&str> = folded.checks.iter().map(|r| r.id.as_ref()).collect();
+        assert_eq!(
+            order,
+            ["http", "ssh", "dns"],
+            "prio first, then deny before allow"
+        );
+        assert!(folded.allow, "the whole-pair allow decides the rest");
+
+        // A whole-pair deny at prio 15 ends the walk before the narrowed rules below it.
+        let mut cut = entries.clone();
+        cut.extend([rule_full(
+            "cut",
+            &["web"],
+            PredicateMatch::Any,
+            &["db"],
+            PredicateMatch::Any,
+            15,
+            false,
+        )]);
+        let rules = resolved(&cut);
+        let folded = fold(firing_rules(&rules, &set(&["web"]), &set(&["db"])));
+        let order: Vec<&str> = folded.checks.iter().map(|r| r.id.as_ref()).collect();
+        assert_eq!(order, ["http"]);
+        assert!(!folded.allow);
+
+        // Only narrowed rules, so the rest is denied.
+        let mut only = entries.clone();
+        only.remove("whole");
+        let rules = resolved(&only);
+        let folded = fold(firing_rules(&rules, &set(&["web"]), &set(&["db"])));
+        assert_eq!(folded.checks.len(), 3);
+        assert!(!folded.allow);
+    }
+
+    #[test]
+    fn bidirectional_narrowing_swaps_the_ports() {
+        let tcp = |sport: Option<&str>, dport: Option<&str>| {
+            Protocol::from_options(&RuleOptions {
+                proto: Some("tcp".to_string()),
+                sport: sport.map(str::to_string),
+                dport: dport.map(str::to_string),
+                ..Default::default()
+            })
+            .expect("valid options")
+            .expect("a protocol")
+        };
+        let check = |rules: &[ResolvedRule], src: &str, dst: &str| {
+            let folded = fold(firing_rules(rules, &set(&[src]), &set(&[dst])));
+            assert_eq!(folded.checks.len(), 1, "one check per direction");
+            folded.checks[0].protocols.clone()
+        };
+
+        let explicit = BTreeMap::from([
+            group("web"),
+            group("db"),
+            bidirectional(narrowed(
+                "bsql",
+                &["web"],
+                &["db"],
+                0,
+                true,
+                Some("tcp"),
+                Some("5432"),
+            )),
+        ]);
+        validate(&explicit).expect("valid");
+        let rules = resolved(&explicit);
+        assert_eq!(check(&rules, "web", "db"), [tcp(None, Some("5432"))]);
+        assert_eq!(
+            check(&rules, "db", "web"),
+            [tcp(Some("5432"), None)],
+            "the reverse direction matches the port as a source port"
+        );
+
+        let from_macro = BTreeMap::from([
+            group("web"),
+            group("db"),
+            bidirectional(with_rule(rule("bhttps", &["web"], &["db"], true), |rule| {
+                rule.fw_macro = Some("HTTPS".into())
+            })),
+        ]);
+        validate(&from_macro).expect("valid");
+        let rules = resolved(&from_macro);
+        assert_eq!(check(&rules, "web", "db"), [tcp(None, Some("443"))]);
+        assert_eq!(check(&rules, "db", "web"), [tcp(Some("443"), None)]);
+    }
 }
diff --git a/proxmox-ve-config/src/sdn/microseg/types.rs b/proxmox-ve-config/src/sdn/microseg/types.rs
index 18367490e076..ee0a03beec51 100644
--- a/proxmox-ve-config/src/sdn/microseg/types.rs
+++ b/proxmox-ve-config/src/sdn/microseg/types.rs
@@ -2,12 +2,16 @@
 
 use std::collections::BTreeMap;
 
-use anyhow::{Error, bail};
+use anyhow::{Error, anyhow, bail};
 use const_format::concatcp;
 use serde::{Deserialize, Serialize};
 
 use proxmox_schema::{ApiStringFormat, ApiType, api, api_string_type, const_regex};
 
+use crate::firewall::fw_macros;
+use crate::firewall::ports::parse_named_protocol;
+use crate::firewall::types::rule_match::{Protocol, RuleOptions};
+
 use super::PredicateMatch;
 use super::identity;
 use super::identity::UNTAGGED_GROUP;
@@ -119,6 +123,36 @@ pub struct GroupSection {
             minimum: 0,
             maximum: 65535,
         },
+        proto: {
+            type: String,
+            optional: true,
+            max_length: 32,
+            description: "IP protocol, a name from /etc/protocols or a number.",
+        },
+        sport: {
+            type: String,
+            optional: true,
+            max_length: 128,
+            description: "Source port list, only with a port-carrying protocol.",
+        },
+        dport: {
+            type: String,
+            optional: true,
+            max_length: 128,
+            description: "Destination port list, only with a port-carrying protocol.",
+        },
+        "icmp-type": {
+            type: String,
+            optional: true,
+            max_length: 64,
+            description: "ICMP type, only with an ICMP protocol.",
+        },
+        "macro": {
+            type: String,
+            optional: true,
+            max_length: 128,
+            description: "Firewall macro standing in for the protocol and ports.",
+        },
         comment: {
             type: String,
             optional: true,
@@ -131,6 +165,9 @@ pub struct GroupSection {
 /// A policy rule. It fires on traffic from an interface whose group set matches `(src_match, src)`
 /// to one whose set matches `(dst_match, dst)`, and decides it with `allow`. A rule ahead of it in
 /// the evaluation order decides first. Traffic no rule decides is denied.
+///
+/// A rule may be narrowed to a protocol with ports or an ICMP type, or to a firewall macro that
+/// stands in for them.
 pub struct RuleSection {
     pub(crate) id: MicrosegId,
     /// Groups the source predicate lists.
@@ -145,7 +182,8 @@ pub struct RuleSection {
     /// How the destination predicate matches its groups.
     #[serde(default, rename = "dst-match")]
     pub(crate) dst_match: PredicateMatch,
-    /// Whether the rule also decides the reverse direction, with the predicates swapped.
+    /// Whether the rule also decides the reverse direction, with the predicates and the ports
+    /// swapped.
     #[serde(
         default,
         deserialize_with = "proxmox_serde::perl::deserialize_bool",
@@ -159,9 +197,83 @@ pub struct RuleSection {
     #[serde(deserialize_with = "proxmox_serde::perl::deserialize_bool")]
     pub(crate) allow: bool,
     #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub(crate) proto: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub(crate) sport: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub(crate) dport: Option<String>,
+    #[serde(default, rename = "icmp-type", skip_serializing_if = "Option::is_none")]
+    pub(crate) icmp_type: Option<String>,
+    #[serde(default, rename = "macro", skip_serializing_if = "Option::is_none")]
+    pub(crate) fw_macro: Option<String>,
+    #[serde(default, skip_serializing_if = "Option::is_none")]
     pub(crate) comment: Option<String>,
 }
 
+impl RuleSection {
+    /// The protocol matches narrowing this rule, empty for a rule that decides the whole pair.
+    /// Ports are only accepted with a port-carrying protocol and an ICMP type only with an ICMP
+    /// protocol, since either would otherwise be silently ignored.
+    pub(super) fn protocols(&self) -> Result<Vec<Protocol>, Error> {
+        let explicit = [&self.proto, &self.sport, &self.dport, &self.icmp_type]
+            .iter()
+            .any(|field| field.is_some());
+        if let Some(name) = &self.fw_macro {
+            if explicit {
+                bail!("a macro cannot be combined with a protocol, ports or an ICMP type");
+            }
+            return match fw_macros::get_macro(name) {
+                Some(fw_macro) => Ok(fw_macro.code.clone()),
+                None => bail!("unknown macro '{name}'"),
+            };
+        }
+        let Some(proto) = &self.proto else {
+            if explicit {
+                bail!("ports and an ICMP type need a protocol");
+            }
+            return Ok(Vec::new());
+        };
+        let proto = proto.trim();
+        let options = RuleOptions {
+            proto: (!proto.is_empty()).then(|| proto.to_string()),
+            sport: self.sport.clone(),
+            dport: self.dport.clone(),
+            icmp_type: self.icmp_type.clone(),
+            ..Default::default()
+        };
+        let mut protocol = Protocol::from_options(&options)?
+            .ok_or_else(|| anyhow!("empty protocol specification"))?;
+        // the data plane matches the number, so an unknown name fails validation rather than the
+        // apply
+        if let Protocol::Named(name) = &protocol {
+            let number = parse_named_protocol(name)?.to_string();
+            let options = RuleOptions {
+                proto: Some(number),
+                ..options
+            };
+            protocol = Protocol::from_options(&options)?
+                .ok_or_else(|| anyhow!("empty protocol specification"))?;
+        }
+        let ports = self.sport.is_some() || self.dport.is_some();
+        let carries_ports = matches!(
+            protocol,
+            Protocol::Tcp(_)
+                | Protocol::Udp(_)
+                | Protocol::Sctp(_)
+                | Protocol::Dccp(_)
+                | Protocol::UdpLite(_)
+        );
+        if ports && !carries_ports {
+            bail!("protocol '{proto}' carries no ports");
+        }
+        let icmp = matches!(protocol, Protocol::Icmp(_) | Protocol::Icmpv6(_));
+        if self.icmp_type.is_some() && !icmp {
+            bail!("an ICMP type needs an ICMP protocol, not '{proto}'");
+        }
+        Ok(vec![protocol])
+    }
+}
+
 fn is_false(value: &bool) -> bool {
     !*value
 }
-- 
2.47.3





  parent reply	other threads:[~2026-10-27  8:57 UTC|newest]

Thread overview: 46+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-27  8:54 [PATCH access-control/cluster/container/docs/manager/network/proxmox{-ebpf,-ve-rs,-perl-rs}/qemu-server v3 00/45] sdn: add microsegmentation support Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 01/45] build: keep the build directory out of the BPF objects Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 02/45] policy: add the enforcement subsystem for guest interfaces Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ebpf v3 03/45] policy: add a check for non-first fragments Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 04/45] ve-config: firewall: add a constructor for sctp matches Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 05/45] ve-config: sdn: microseg: add identity allocation for group sets Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 06/45] ve-config: sdn: microseg: add config types Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 07/45] ve-config: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 08/45] ve-config: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:54 ` Hannes Laimer [this message]
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 10/45] ve-config: sdn: microseg: add group hierarchy Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 11/45] ve-config: firewall: expose protocol, icmp type and code numbers Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 12/45] ve-config: sdn: microseg: add the desired state Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 13/45] microseg: add a crate that applies the policy to a node's guest NICs Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-ve-rs v3 14/45] microseg: add a rule option that matches non-first fragments Hannes Laimer
2026-10-27  8:54 ` [PATCH pve-cluster v3 15/45] cfs: add 'sdn/microseg.cfg' to observed files Hannes Laimer
2026-10-27  8:54 ` [PATCH pve-access-control v3 16/45] permissions: add ACL path for SDN microsegmentation Hannes Laimer
2026-10-27  8:54 ` [PATCH proxmox-perl-rs v3 17/45] pve-rs: sdn: add microseg config binding Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 18/45] pve-rs: sdn: microseg: bind the data plane Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 19/45] pve-rs: sdn: microseg: check the data plane limits on render Hannes Laimer
2026-10-27  8:55 ` [PATCH proxmox-perl-rs v3 20/45] pve-rs: debian: fire the pve-microseg-update trigger on upgrade Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 21/45] sdn: zones: add vxlan-gbp option to vxlan and evpn zones Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 22/45] sdn: microseg: add config, API and guest inventory Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 23/45] sdn: dry-run: show pending microseg changes Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 24/45] sdn: microseg: enforce the policy on guest NICs Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 25/45] sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 26/45] sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 27/45] sdn: microseg: add an update for a running NIC Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 28/45] sdn: microseg: expose protocol, port and macro rule options Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 29/45] sdn: microseg: allow nesting groups under a parent Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-network v3 30/45] sdn: microseg: expose the fragments rule option Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 31/45] network: apply the microseg policy on reload Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 32/45] pve-sdn-commit: apply the microseg policy at boot Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 33/45] ui: sdn: zones: add vxlan-gbp checkbox to vxlan and evpn Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 34/45] ui: sdn: add microsegmentation panel Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 35/45] ui: sdn: dry-run: show pending microseg diff Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 36/45] ui: sdn: microseg: add tag matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 37/45] ui: sdn: microseg: add name regex matcher Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 38/45] ui: sdn: microseg: add narrowing to the rule form Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 39/45] ui: sdn: microseg: show and edit the group hierarchy Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 40/45] ui: sdn: microseg: add fragments option to the rule form Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-manager v3 41/45] ui: sdn: microseg: add a guest tab with its groups and rules Hannes Laimer
2026-10-27  8:55 ` [PATCH qemu-server v3 42/45] network: update the microseg enforcement on a tag or name change Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-container v3 43/45] lxc: update the microseg enforcement on a tag or hostname change Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-docs v3 44/45] sdn: zones: document the VXLAN-GBP option Hannes Laimer
2026-10-27  8:55 ` [PATCH pve-docs v3 45/45] sdn: add microsegmentation section Hannes Laimer

Reply instructions:

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

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

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

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

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

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

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