From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id 11E221FF142 for ; Mon, 16 Feb 2026 11:45:33 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 960A0D999; Mon, 16 Feb 2026 11:45:10 +0100 (CET) From: Dietmar Maurer To: pve-devel@lists.proxmox.com Subject: [RFC proxmox 10/22] firewall-api-types: add FirewallPortList types Date: Mon, 16 Feb 2026 11:43:48 +0100 Message-ID: <20260216104401.3959270-11-dietmar@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20260216104401.3959270-1-dietmar@proxmox.com> References: <20260216104401.3959270-1-dietmar@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-SPAM-LEVEL: Spam detection results: 0 AWL -0.572 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 KAM_LAZY_DOMAIN_SECURITY 1 Sending domain does not have any anti-forgery methods RDNS_NONE 0.793 Delivered to internal network by a host with no rDNS SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_NONE 0.001 SPF: sender does not publish an SPF Record Message-ID-Hash: KJBKE5ERLYQTBV7XJ6ZFPAPMRLT2CAJ5 X-Message-ID-Hash: KJBKE5ERLYQTBV7XJ6ZFPAPMRLT2CAJ5 X-MailFrom: dietmar@zilli.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: Add new port specification types for firewall rules: - FirewallPortListEntry: single numeric port, numeric port range or named service - FirewallPortList: comma-separated list of port entries Includes comprehensive parsing with validation and unit tests. Signed-off-by: Dietmar Maurer --- proxmox-firewall-api-types/Cargo.toml | 1 + proxmox-firewall-api-types/src/lib.rs | 5 + proxmox-firewall-api-types/src/port.rs | 173 +++++++++++++++++++++++++ 3 files changed, 179 insertions(+) create mode 100644 proxmox-firewall-api-types/src/port.rs diff --git a/proxmox-firewall-api-types/Cargo.toml b/proxmox-firewall-api-types/Cargo.toml index 3122d813..97b477b8 100644 --- a/proxmox-firewall-api-types/Cargo.toml +++ b/proxmox-firewall-api-types/Cargo.toml @@ -15,6 +15,7 @@ enum-fallback = ["dep:proxmox-fixed-string"] [dependencies] anyhow.workspace = true regex.workspace = true +const_format.workspace = true serde = { workspace = true, features = [ "derive" ] } serde_plain = { workspace = true } diff --git a/proxmox-firewall-api-types/src/lib.rs b/proxmox-firewall-api-types/src/lib.rs index 993115d8..b099be0c 100644 --- a/proxmox-firewall-api-types/src/lib.rs +++ b/proxmox-firewall-api-types/src/lib.rs @@ -20,3 +20,8 @@ pub use node_options::FirewallNodeOptions; mod firewall_ref; pub use firewall_ref::{FirewallRef, FirewallRefType}; + +mod port; +pub use port::{ + FirewallPortList, FirewallPortListEntry, FIREWALL_DPORT_API_SCHEMA, FIREWALL_SPORT_API_SCHEMA, +}; diff --git a/proxmox-firewall-api-types/src/port.rs b/proxmox-firewall-api-types/src/port.rs new file mode 100644 index 00000000..46989ba4 --- /dev/null +++ b/proxmox-firewall-api-types/src/port.rs @@ -0,0 +1,173 @@ +use std::fmt::Display; +use std::str::FromStr; + +use anyhow::{bail, Error}; +use const_format::concatcp; +use proxmox_schema::{ApiStringFormat, Schema, StringSchema, UpdaterType}; + +#[derive(Clone, Debug, PartialEq)] +/// Single entry in a TCP/UDP port list. +/// +/// Can be a named service, a numeric port or a port range. +pub enum FirewallPortListEntry { + Named(String), + Numeric(u16), + Range(u16, u16), +} + +impl FromStr for FirewallPortListEntry { + type Err = Error; + fn from_str(s: &str) -> Result { + Ok(match s.trim().split_once(':') { + None => { + if s.is_empty() { + bail!("empty port specification"); + } + if s.find(|c: char| !(c.is_digit(10))).is_some() { + // Note: arbitrary length limit, longer than anything in /etc/services + if s.len() < 256 { + if s.contains(|c: char| !(c.is_ascii_alphanumeric() || c == '-')) { + bail!("invalid characters in port name"); + } + FirewallPortListEntry::Named(s.to_string()) + } else { + bail!("port name too long"); + } + } else { + let port = s.parse::()?; + FirewallPortListEntry::Numeric(port) + } + } + Some((first, second)) => { + let first = first.parse::()?; + let second = second.parse::()?; + if first > second { + bail!("invalid port range: start port greater than end port") + } + FirewallPortListEntry::Range(first, second) + } + }) + } +} + +impl Display for FirewallPortListEntry { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + FirewallPortListEntry::Named(name) => write!(f, "{}", name), + FirewallPortListEntry::Numeric(number) => write!(f, "{}", number), + FirewallPortListEntry::Range(first, second) => write!(f, "{}:{}", first, second), + } + } +} + +#[derive(Clone, Debug, PartialEq)] +/// TCP/UDP port list. +pub struct FirewallPortList(pub Vec); + +const PORT_FORMAT_DESCRIPTION: &'static str = r#"You can use service names or simple numbers (0-65535), +as defined in '/etc/services'. Port ranges can be specified with '\d+:\d+', +for example '80:85', and you can use comma separated list to match several ports or ranges."#; + +/// API schema for firewall source port list. +pub const FIREWALL_SPORT_API_SCHEMA: Schema = StringSchema::new(concatcp!( + "Restrict TCP/UDP source port. ", + PORT_FORMAT_DESCRIPTION +)) +.format(&ApiStringFormat::VerifyFn(verify_firewall_port_list)) +.schema(); + +/// API schema for firewall destination port list. +pub const FIREWALL_DPORT_API_SCHEMA: Schema = StringSchema::new(concatcp!( + "Restrict TCP/UDP destination port. ", + PORT_FORMAT_DESCRIPTION +)) +.format(&ApiStringFormat::VerifyFn(verify_firewall_port_list)) +.schema(); + +serde_plain::derive_deserialize_from_fromstr!(FirewallPortList, "valid port list"); +serde_plain::derive_serialize_from_display!(FirewallPortList); + +impl FromStr for FirewallPortList { + type Err = Error; + fn from_str(s: &str) -> Result { + let mut res = Vec::new(); + for part in s.split(',') { + let entry = FirewallPortListEntry::from_str(part.trim())?; + res.push(entry); + } + Ok(FirewallPortList(res)) + } +} + +impl Display for FirewallPortList { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + for (i, entry) in self.0.iter().enumerate() { + if i > 0 { + write!(f, ",")?; + } + write!(f, "{}", entry)?; + } + Ok(()) + } +} + +fn verify_firewall_port_list(s: &str) -> Result<(), Error> { + FirewallPortList::from_str(s)?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_port_entry() { + let mut port_entry: FirewallPortListEntry = "12345".parse().expect("valid port entry"); + assert_eq!(port_entry, FirewallPortListEntry::Numeric(12345)); + + port_entry = "0:65535".parse().expect("valid port entry"); + assert_eq!(port_entry, FirewallPortListEntry::Range(0, 65535)); + + "ssh:80".parse::().unwrap_err(); + "65536".parse::().unwrap_err(); + "100:80".parse::().unwrap_err(); + "100:100000".parse::().unwrap_err(); + "any-name".parse::().unwrap(); + "TOS-network-unreachable" + .parse::() + .unwrap(); + "no_underscores" + .parse::() + .unwrap_err(); + "imap2".parse::().unwrap(); + "".parse::().unwrap_err(); + } + + #[test] + fn test_parse_port_list() { + let mut port_list = FirewallPortList::from_str("12345").expect("valid port list"); + assert_eq!( + port_list, + FirewallPortList(vec![FirewallPortListEntry::Numeric(12345)]) + ); + + port_list = + FirewallPortList::from_str("12345,0:65535,1337,https").expect("valid port list"); + + assert_eq!( + port_list, + FirewallPortList(vec![ + FirewallPortListEntry::from_str("12345").unwrap(), + FirewallPortListEntry::from_str("0:65535").unwrap(), + FirewallPortListEntry::from_str("1337").unwrap(), + FirewallPortListEntry::from_str("https").unwrap(), + ]) + ); + + FirewallPortList::from_str("0::1337").unwrap_err(); + FirewallPortList::from_str("0:1337,").unwrap_err(); + FirewallPortList::from_str("70000").unwrap_err(); + FirewallPortList::from_str("ssh:80").unwrap_err(); + FirewallPortList::from_str("").unwrap_err(); + } +} -- 2.47.3