public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
* [pdm-devel] [PATCH proxmox{, -datacenter-manager, -yew-comp} v2 0/4] make security groups expandable in firewall rules list
@ 2025-12-17 16:17 Hannes Laimer
  2025-12-17 16:17 ` [pdm-devel] [PATCH proxmox v2 1/2] pve-api-types: add security group GET endpoints Hannes Laimer
                   ` (3 more replies)
  0 siblings, 4 replies; 5+ messages in thread
From: Hannes Laimer @ 2025-12-17 16:17 UTC (permalink / raw)
  To: pdm-devel

Currently we don't really know what a security group actually contains,
in the list currently it's a bit of a black box what a group actually
does. Finding out what rules it contains is a little cumbersome. This
should make that easier. It seemed like a good place too, I considerd an
extra tab maybe. But especially for read-only I think this is better.

v2, thanks @Lukas:
 - show positions of rules in groups as `3.1`
 - highlight rules in groups and add small visual hint so it is clear
   these rules are part of the group
 - add placeholder for groups with no rules
 - split the rename into a small, separate series[1]
 - generally reworked code a little

(this does depend on [1], it uses the re-named name)

[1] https://lore.proxmox.com/pdm-devel/20251217150831.199100-1-h.laimer@proxmox.com/T/#t 

proxmox:

Hannes Laimer (2):
  pve-api-types: add security group GET endpoints
  pve-api-types: regenerate

 pve-api-types/generate.pl            |   5 +
 pve-api-types/src/generated/code.rs  |  77 ++++++-
 pve-api-types/src/generated/types.rs | 294 +++++++++++++++------------
 3 files changed, 234 insertions(+), 142 deletions(-)


proxmox-datacenter-manager:

Hannes Laimer (1):
  api: firewall: add pve firewall security group GET endpoints

 server/src/api/pve/firewall.rs | 65 ++++++++++++++++++++++++++++++++++
 1 file changed, 65 insertions(+)


proxmox-yew-comp:

Hannes Laimer (1):
  firewall: rules: make security group entries expandable

 src/firewall/context.rs |  12 ++
 src/firewall/rules.rs   | 384 +++++++++++++++++++++++++++++++++++++---
 2 files changed, 369 insertions(+), 27 deletions(-)


Summary over all repositories:
  6 files changed, 668 insertions(+), 169 deletions(-)

-- 
Generated by git-murpp 0.8.1


_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

* [pdm-devel] [PATCH proxmox v2 1/2] pve-api-types: add security group GET endpoints
  2025-12-17 16:17 [pdm-devel] [PATCH proxmox{, -datacenter-manager, -yew-comp} v2 0/4] make security groups expandable in firewall rules list Hannes Laimer
@ 2025-12-17 16:17 ` Hannes Laimer
  2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox v2 2/2] pve-api-types: regenerate Hannes Laimer
                   ` (2 subsequent siblings)
  3 siblings, 0 replies; 5+ messages in thread
From: Hannes Laimer @ 2025-12-17 16:17 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pve-api-types/generate.pl | 5 +++++
 1 file changed, 5 insertions(+)

diff --git a/pve-api-types/generate.pl b/pve-api-types/generate.pl
index 17f7e1e5..599802c8 100755
--- a/pve-api-types/generate.pl
+++ b/pve-api-types/generate.pl
@@ -398,6 +398,11 @@ Schema2Rust::register_format('pve-fw-conntrack-helper' => {
     kind => 'array',
 });
 
+# security groups
+api(GET => '/cluster/firewall/groups', 'list_firewall_security_groups', 'return-name' => 'FirewallSecurityGroup');
+api(GET => '/cluster/firewall/groups/{group}', 'list_firewall_security_group_rules', 'return-name' => 'FirewallRule');
+api(GET => '/cluster/firewall/groups/{group}/{pos}', 'firewall_security_group_rule', 'return-name' => 'FirewallRule');
+
 # options
 # FIXME: to use a better return value than `Value`, we first must fix the return schema there
 api(GET => '/cluster/options', 'cluster_options', 'output-type' => 'serde_json::Value');
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

* [pdm-devel] [PATCH proxmox v2 2/2] pve-api-types: regenerate
  2025-12-17 16:17 [pdm-devel] [PATCH proxmox{, -datacenter-manager, -yew-comp} v2 0/4] make security groups expandable in firewall rules list Hannes Laimer
  2025-12-17 16:17 ` [pdm-devel] [PATCH proxmox v2 1/2] pve-api-types: add security group GET endpoints Hannes Laimer
@ 2025-12-17 16:18 ` Hannes Laimer
  2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/1] api: firewall: add pve firewall security group GET endpoints Hannes Laimer
  2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox-yew-comp v2 1/1] firewall: rules: make security group entries expandable Hannes Laimer
  3 siblings, 0 replies; 5+ messages in thread
From: Hannes Laimer @ 2025-12-17 16:18 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 pve-api-types/src/generated/code.rs  |  77 ++++++-
 pve-api-types/src/generated/types.rs | 294 +++++++++++++++------------
 2 files changed, 229 insertions(+), 142 deletions(-)

diff --git a/pve-api-types/src/generated/code.rs b/pve-api-types/src/generated/code.rs
index f364f9cd..b583c2e0 100644
--- a/pve-api-types/src/generated/code.rs
+++ b/pve-api-types/src/generated/code.rs
@@ -60,9 +60,6 @@
 /// - /cluster/firewall
 /// - /cluster/firewall/aliases
 /// - /cluster/firewall/aliases/{name}
-/// - /cluster/firewall/groups
-/// - /cluster/firewall/groups/{group}
-/// - /cluster/firewall/groups/{group}/{pos}
 /// - /cluster/firewall/ipset
 /// - /cluster/firewall/ipset/{name}
 /// - /cluster/firewall/ipset/{name}/{cidr}
@@ -445,6 +442,15 @@ pub trait PveClient {
         Err(Error::Other("create_zone not implemented"))
     }
 
+    /// Get single rule data.
+    async fn firewall_security_group_rule(
+        &self,
+        group: &str,
+        pos: u64,
+    ) -> Result<FirewallRule, Error> {
+        Err(Error::Other("firewall_security_group_rule not implemented"))
+    }
+
     /// Get APT repository information.
     async fn get_apt_repositories(&self, node: &str) -> Result<APTRepositoriesResult, Error> {
         Err(Error::Other("get_apt_repositories not implemented"))
@@ -512,7 +518,7 @@ pub trait PveClient {
     }
 
     /// List rules.
-    async fn list_cluster_firewall_rules(&self) -> Result<Vec<ListFirewallRules>, Error> {
+    async fn list_cluster_firewall_rules(&self) -> Result<Vec<FirewallRule>, Error> {
         Err(Error::Other("list_cluster_firewall_rules not implemented"))
     }
 
@@ -531,6 +537,23 @@ pub trait PveClient {
         Err(Error::Other("list_domains not implemented"))
     }
 
+    /// List rules.
+    async fn list_firewall_security_group_rules(
+        &self,
+        group: &str,
+    ) -> Result<Vec<FirewallRule>, Error> {
+        Err(Error::Other(
+            "list_firewall_security_group_rules not implemented",
+        ))
+    }
+
+    /// List security groups.
+    async fn list_firewall_security_groups(&self) -> Result<Vec<FirewallSecurityGroup>, Error> {
+        Err(Error::Other(
+            "list_firewall_security_groups not implemented",
+        ))
+    }
+
     /// LXC container index (per node).
     async fn list_lxc(&self, node: &str) -> Result<Vec<LxcEntry>, Error> {
         Err(Error::Other("list_lxc not implemented"))
@@ -541,7 +564,7 @@ pub trait PveClient {
         &self,
         node: &str,
         vmid: u32,
-    ) -> Result<Vec<ListFirewallRules>, Error> {
+    ) -> Result<Vec<FirewallRule>, Error> {
         Err(Error::Other("list_lxc_firewall_rules not implemented"))
     }
 
@@ -555,7 +578,7 @@ pub trait PveClient {
     }
 
     /// List rules.
-    async fn list_node_firewall_rules(&self, node: &str) -> Result<Vec<ListFirewallRules>, Error> {
+    async fn list_node_firewall_rules(&self, node: &str) -> Result<Vec<FirewallRule>, Error> {
         Err(Error::Other("list_node_firewall_rules not implemented"))
     }
 
@@ -574,7 +597,7 @@ pub trait PveClient {
         &self,
         node: &str,
         vmid: u32,
-    ) -> Result<Vec<ListFirewallRules>, Error> {
+    ) -> Result<Vec<FirewallRule>, Error> {
         Err(Error::Other("list_qemu_firewall_rules not implemented"))
     }
 
@@ -1080,6 +1103,20 @@ where
         self.0.post(url, &params).await?.nodata()
     }
 
+    /// Get single rule data.
+    async fn firewall_security_group_rule(
+        &self,
+        group: &str,
+        pos: u64,
+    ) -> Result<FirewallRule, Error> {
+        let url = &format!(
+            "/api2/extjs/cluster/firewall/groups/{}/{}",
+            percent_encode(group.as_bytes(), percent_encoding::NON_ALPHANUMERIC),
+            pos
+        );
+        Ok(self.0.get(url).await?.expect_json()?.data)
+    }
+
     /// Get APT repository information.
     async fn get_apt_repositories(&self, node: &str) -> Result<APTRepositoriesResult, Error> {
         let url = &format!(
@@ -1222,7 +1259,7 @@ where
     }
 
     /// List rules.
-    async fn list_cluster_firewall_rules(&self) -> Result<Vec<ListFirewallRules>, Error> {
+    async fn list_cluster_firewall_rules(&self) -> Result<Vec<FirewallRule>, Error> {
         let url = "/api2/extjs/cluster/firewall/rules";
         Ok(self.0.get(url).await?.expect_json()?.data)
     }
@@ -1248,6 +1285,24 @@ where
         Ok(self.0.get(url).await?.expect_json()?.data)
     }
 
+    /// List rules.
+    async fn list_firewall_security_group_rules(
+        &self,
+        group: &str,
+    ) -> Result<Vec<FirewallRule>, Error> {
+        let url = &format!(
+            "/api2/extjs/cluster/firewall/groups/{}",
+            percent_encode(group.as_bytes(), percent_encoding::NON_ALPHANUMERIC)
+        );
+        Ok(self.0.get(url).await?.expect_json()?.data)
+    }
+
+    /// List security groups.
+    async fn list_firewall_security_groups(&self) -> Result<Vec<FirewallSecurityGroup>, Error> {
+        let url = "/api2/extjs/cluster/firewall/groups";
+        Ok(self.0.get(url).await?.expect_json()?.data)
+    }
+
     /// LXC container index (per node).
     async fn list_lxc(&self, node: &str) -> Result<Vec<LxcEntry>, Error> {
         let url = &format!(
@@ -1262,7 +1317,7 @@ where
         &self,
         node: &str,
         vmid: u32,
-    ) -> Result<Vec<ListFirewallRules>, Error> {
+    ) -> Result<Vec<FirewallRule>, Error> {
         let url = &format!(
             "/api2/extjs/nodes/{}/lxc/{}/firewall/rules",
             percent_encode(node.as_bytes(), percent_encoding::NON_ALPHANUMERIC),
@@ -1287,7 +1342,7 @@ where
     }
 
     /// List rules.
-    async fn list_node_firewall_rules(&self, node: &str) -> Result<Vec<ListFirewallRules>, Error> {
+    async fn list_node_firewall_rules(&self, node: &str) -> Result<Vec<FirewallRule>, Error> {
         let url = &format!(
             "/api2/extjs/nodes/{}/firewall/rules",
             percent_encode(node.as_bytes(), percent_encoding::NON_ALPHANUMERIC)
@@ -1317,7 +1372,7 @@ where
         &self,
         node: &str,
         vmid: u32,
-    ) -> Result<Vec<ListFirewallRules>, Error> {
+    ) -> Result<Vec<FirewallRule>, Error> {
         let url = &format!(
             "/api2/extjs/nodes/{}/qemu/{}/firewall/rules",
             percent_encode(node.as_bytes(), percent_encoding::NON_ALPHANUMERIC),
diff --git a/pve-api-types/src/generated/types.rs b/pve-api-types/src/generated/types.rs
index 26f07a5a..b7dc983f 100644
--- a/pve-api-types/src/generated/types.rs
+++ b/pve-api-types/src/generated/types.rs
@@ -1891,6 +1891,169 @@ pub enum FirewallLogLevel {
 serde_plain::derive_display_from_serialize!(FirewallLogLevel);
 serde_plain::derive_fromstr_from_deserialize!(FirewallLogLevel);
 
+#[api(
+    properties: {
+        action: {
+            type: String,
+        },
+        comment: {
+            optional: true,
+            type: String,
+        },
+        dest: {
+            optional: true,
+            type: String,
+        },
+        dport: {
+            optional: true,
+            type: String,
+        },
+        enable: {
+            optional: true,
+            type: Integer,
+        },
+        "icmp-type": {
+            optional: true,
+            type: String,
+        },
+        iface: {
+            optional: true,
+            type: String,
+        },
+        ipversion: {
+            optional: true,
+            type: Integer,
+        },
+        log: {
+            optional: true,
+            type: FirewallLogLevel,
+        },
+        "macro": {
+            optional: true,
+            type: String,
+        },
+        pos: {
+            type: Integer,
+        },
+        proto: {
+            optional: true,
+            type: String,
+        },
+        source: {
+            optional: true,
+            type: String,
+        },
+        sport: {
+            optional: true,
+            type: String,
+        },
+        type: {
+            type: String,
+        },
+    },
+)]
+/// Object.
+#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
+pub struct FirewallRule {
+    /// Rule action ('ACCEPT', 'DROP', 'REJECT') or security group name
+    pub action: String,
+
+    /// Descriptive comment
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+
+    /// Restrict packet destination address
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub dest: Option<String>,
+
+    /// Restrict TCP/UDP destination port
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub dport: Option<String>,
+
+    /// Flag to enable/disable a rule
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_i64")]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub enable: Option<i64>,
+
+    /// Specify icmp-type. Only valid if proto equals 'icmp' or
+    /// 'icmpv6'/'ipv6-icmp'
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[serde(rename = "icmp-type")]
+    pub icmp_type: Option<String>,
+
+    /// Network interface name. You have to use network configuration key names
+    /// for VMs and containers
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub iface: Option<String>,
+
+    /// IP version (4 or 6) - automatically determined from source/dest
+    /// addresses
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_i64")]
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub ipversion: Option<i64>,
+
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub log: Option<FirewallLogLevel>,
+
+    /// Use predefined standard macro
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    #[serde(rename = "macro")]
+    pub r#macro: Option<String>,
+
+    /// Rule position in the ruleset
+    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_i64")]
+    pub pos: i64,
+
+    /// IP protocol. You can use protocol names ('tcp'/'udp') or simple numbers,
+    /// as defined in '/etc/protocols'
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub proto: Option<String>,
+
+    /// Restrict packet source address
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub source: Option<String>,
+
+    /// Restrict TCP/UDP source port
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub sport: Option<String>,
+
+    /// Rule type
+    #[serde(rename = "type")]
+    pub ty: String,
+}
+
+#[api(
+    properties: {
+        comment: {
+            optional: true,
+            type: String,
+        },
+        digest: {
+            max_length: 64,
+            type: String,
+        },
+        group: {
+            max_length: 18,
+            min_length: 2,
+            type: String,
+        },
+    },
+)]
+/// Object.
+#[derive(Debug, serde::Deserialize, serde::Serialize)]
+pub struct FirewallSecurityGroup {
+    /// Optional comment or description.
+    #[serde(default, skip_serializing_if = "Option::is_none")]
+    pub comment: Option<String>,
+
+    /// Prevent changes if current configuration file has a different digest.
+    /// This can be used to prevent concurrent modifications.
+    pub digest: String,
+
+    /// Security Group name.
+    pub group: String,
+}
+
 #[api]
 /// Firewall conntrack helper.
 #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
@@ -2191,137 +2354,6 @@ pub enum ListControllersType {
 serde_plain::derive_display_from_serialize!(ListControllersType);
 serde_plain::derive_fromstr_from_deserialize!(ListControllersType);
 
-#[api(
-    properties: {
-        action: {
-            type: String,
-        },
-        comment: {
-            optional: true,
-            type: String,
-        },
-        dest: {
-            optional: true,
-            type: String,
-        },
-        dport: {
-            optional: true,
-            type: String,
-        },
-        enable: {
-            optional: true,
-            type: Integer,
-        },
-        "icmp-type": {
-            optional: true,
-            type: String,
-        },
-        iface: {
-            optional: true,
-            type: String,
-        },
-        ipversion: {
-            optional: true,
-            type: Integer,
-        },
-        log: {
-            optional: true,
-            type: FirewallLogLevel,
-        },
-        "macro": {
-            optional: true,
-            type: String,
-        },
-        pos: {
-            type: Integer,
-        },
-        proto: {
-            optional: true,
-            type: String,
-        },
-        source: {
-            optional: true,
-            type: String,
-        },
-        sport: {
-            optional: true,
-            type: String,
-        },
-        type: {
-            type: String,
-        },
-    },
-)]
-/// Object.
-#[derive(Clone, Debug, PartialEq, serde::Deserialize, serde::Serialize)]
-pub struct ListFirewallRules {
-    /// Rule action ('ACCEPT', 'DROP', 'REJECT') or security group name
-    pub action: String,
-
-    /// Descriptive comment
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub comment: Option<String>,
-
-    /// Restrict packet destination address
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub dest: Option<String>,
-
-    /// Restrict TCP/UDP destination port
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub dport: Option<String>,
-
-    /// Flag to enable/disable a rule
-    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_i64")]
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub enable: Option<i64>,
-
-    /// Specify icmp-type. Only valid if proto equals 'icmp' or
-    /// 'icmpv6'/'ipv6-icmp'
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "icmp-type")]
-    pub icmp_type: Option<String>,
-
-    /// Network interface name. You have to use network configuration key names
-    /// for VMs and containers
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub iface: Option<String>,
-
-    /// IP version (4 or 6) - automatically determined from source/dest
-    /// addresses
-    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_i64")]
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub ipversion: Option<i64>,
-
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub log: Option<FirewallLogLevel>,
-
-    /// Use predefined standard macro
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    #[serde(rename = "macro")]
-    pub r#macro: Option<String>,
-
-    /// Rule position in the ruleset
-    #[serde(deserialize_with = "proxmox_serde::perl::deserialize_i64")]
-    pub pos: i64,
-
-    /// IP protocol. You can use protocol names ('tcp'/'udp') or simple numbers,
-    /// as defined in '/etc/protocols'
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub proto: Option<String>,
-
-    /// Restrict packet source address
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub source: Option<String>,
-
-    /// Restrict TCP/UDP source port
-    #[serde(default, skip_serializing_if = "Option::is_none")]
-    pub sport: Option<String>,
-
-    /// Rule type
-    #[serde(rename = "type")]
-    pub ty: String,
-}
-
 #[api]
 /// Only list specific interface types.
 #[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

* [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/1] api: firewall: add pve firewall security group GET endpoints
  2025-12-17 16:17 [pdm-devel] [PATCH proxmox{, -datacenter-manager, -yew-comp} v2 0/4] make security groups expandable in firewall rules list Hannes Laimer
  2025-12-17 16:17 ` [pdm-devel] [PATCH proxmox v2 1/2] pve-api-types: add security group GET endpoints Hannes Laimer
  2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox v2 2/2] pve-api-types: regenerate Hannes Laimer
@ 2025-12-17 16:18 ` Hannes Laimer
  2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox-yew-comp v2 1/1] firewall: rules: make security group entries expandable Hannes Laimer
  3 siblings, 0 replies; 5+ messages in thread
From: Hannes Laimer @ 2025-12-17 16:18 UTC (permalink / raw)
  To: pdm-devel

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 server/src/api/pve/firewall.rs | 65 ++++++++++++++++++++++++++++++++++
 1 file changed, 65 insertions(+)

diff --git a/server/src/api/pve/firewall.rs b/server/src/api/pve/firewall.rs
index e60961c..7957264 100644
--- a/server/src/api/pve/firewall.rs
+++ b/server/src/api/pve/firewall.rs
@@ -47,6 +47,7 @@ const PVE_FW_SUBDIRS: SubdirMap = &sorted!([("status", &PVE_STATUS_ROUTER),]);
 // cluster
 #[sortable]
 const CLUSTER_FW_SUBDIRS: SubdirMap = &sorted!([
+    ("groups", &FIREWALL_SECURITY_GROUPS_ROUTER),
     ("options", &CLUSTER_OPTIONS_ROUTER),
     ("rules", &CLUSTER_RULES_ROUTER),
     ("status", &CLUSTER_STATUS_ROUTER),
@@ -72,6 +73,13 @@ const QEMU_FW_SUBDIRS: SubdirMap = &sorted!([
     ("rules", &QEMU_RULES_ROUTER),
 ]);
 
+// /groups
+const FIREWALL_SECURITY_GROUPS_ROUTER: Router = Router::new()
+    .get(&API_METHOD_FIREWALL_SECURITY_GROUPS)
+    .match_all("group", &FIREWALL_SECURITY_GROUP_ROUTER);
+const FIREWALL_SECURITY_GROUP_ROUTER: Router =
+    Router::new().get(&API_METHOD_FIREWALL_SECURITY_GROUP);
+
 // /options
 const CLUSTER_OPTIONS_ROUTER: Router = Router::new()
     .get(&API_METHOD_CLUSTER_FIREWALL_OPTIONS)
@@ -331,6 +339,63 @@ pub async fn pve_firewall_status(
     Ok(result)
 }
 
+#[api(
+    input: {
+        properties: {
+            remote: { schema: REMOTE_ID_SCHEMA },
+        },
+    },
+    returns: {
+        type: Array,
+        description: "List of firewall security groups.",
+        items: { type: pve_api_types::FirewallSecurityGroup },
+    },
+    access: {
+        permission: &Permission::Privilege(&["resource", "{remote}"], PRIV_RESOURCE_AUDIT, false),
+    },
+)]
+/// Get firewall security groups.
+pub async fn firewall_security_groups(
+    remote: String,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<pve_api_types::FirewallSecurityGroup>, Error> {
+    let (remotes, _) = pdm_config::remotes::config()?;
+    let pve = connect_to_remote(&remotes, &remote)?;
+
+    Ok(pve.list_firewall_security_groups().await?)
+}
+
+#[api(
+    input: {
+        properties: {
+            remote: { schema: REMOTE_ID_SCHEMA },
+            group: {
+                type: String,
+                description: "The security groups name",
+            },
+        },
+    },
+    returns: {
+        type: Array,
+        description: "List firewall security group rules.",
+        items: { type: pve_api_types::FirewallRule },
+    },
+    access: {
+        permission: &Permission::Privilege(&["resource", "{remote}"], PRIV_RESOURCE_AUDIT, false),
+    },
+)]
+/// Get firewall security group rules.
+pub async fn firewall_security_group(
+    remote: String,
+    group: String,
+    _rpcenv: &mut dyn RpcEnvironment,
+) -> Result<Vec<pve_api_types::FirewallRule>, Error> {
+    let (remotes, _) = pdm_config::remotes::config()?;
+    let pve = connect_to_remote(&remotes, &remote)?;
+
+    Ok(pve.list_firewall_security_group_rules(&group).await?)
+}
+
 #[api(
     input: {
         properties: {
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


^ permalink raw reply	[flat|nested] 5+ messages in thread

* [pdm-devel] [PATCH proxmox-yew-comp v2 1/1] firewall: rules: make security group entries expandable
  2025-12-17 16:17 [pdm-devel] [PATCH proxmox{, -datacenter-manager, -yew-comp} v2 0/4] make security groups expandable in firewall rules list Hannes Laimer
                   ` (2 preceding siblings ...)
  2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/1] api: firewall: add pve firewall security group GET endpoints Hannes Laimer
@ 2025-12-17 16:18 ` Hannes Laimer
  3 siblings, 0 replies; 5+ messages in thread
From: Hannes Laimer @ 2025-12-17 16:18 UTC (permalink / raw)
  To: pdm-devel

With this is is possible to see what rules a security group contains
within the list of normal firewall rules.

Signed-off-by: Hannes Laimer <h.laimer@proxmox.com>
---
 src/firewall/context.rs |  12 ++
 src/firewall/rules.rs   | 384 +++++++++++++++++++++++++++++++++++++---
 2 files changed, 369 insertions(+), 27 deletions(-)

diff --git a/src/firewall/context.rs b/src/firewall/context.rs
index 6495fa3..79aa69b 100644
--- a/src/firewall/context.rs
+++ b/src/firewall/context.rs
@@ -82,6 +82,18 @@ impl FirewallContext {
         }
     }
 
+    pub fn group_rules_url(&self, group: &str) -> String {
+        match self {
+            Self::Cluster { remote } | Self::Node { remote, .. } | Self::Guest { remote, .. } => {
+                format!(
+                    "/pve/remotes/{}/firewall/groups/{}",
+                    percent_encode_component(remote),
+                    percent_encode_component(group)
+                )
+            }
+        }
+    }
+
     pub fn options_url(&self) -> String {
         match self {
             Self::Cluster { remote } => {
diff --git a/src/firewall/rules.rs b/src/firewall/rules.rs
index c40fab7..9ee7f56 100644
--- a/src/firewall/rules.rs
+++ b/src/firewall/rules.rs
@@ -1,12 +1,17 @@
+use std::collections::HashMap;
+use std::collections::HashSet;
 use std::rc::Rc;
 
 use yew::html::{IntoEventCallback, IntoPropValue};
 use yew::virtual_dom::{Key, VComp, VNode};
 
 use pwt::prelude::*;
-use pwt::state::{Loader, LoaderState, SharedStateObserver, Store};
-use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
-use pwt::widget::{Container, Fa};
+use pwt::props::ExtractPrimaryKey;
+use pwt::state::{Loader, LoaderState, SharedStateObserver, SlabTree, TreeStore};
+use pwt::widget::data_table::{
+    DataTable, DataTableCellRenderArgs, DataTableCellRenderer, DataTableColumn, DataTableHeader,
+};
+use pwt::widget::{Container, Fa, Row};
 use pwt_macros::builder;
 
 use super::context::FirewallContext;
@@ -76,16 +81,111 @@ impl FirewallRules {
     }
 }
 
+#[derive(Clone, Debug, PartialEq)]
+pub enum FirewallRuleEntry {
+    Root,
+    Rule {
+        rule: pve_api_types::FirewallRule,
+        parent_pos: Option<i64>,
+    },
+    Placeholder {
+        kind: PlaceholderKind,
+        parent_pos: i64,
+    },
+}
+
+#[derive(Copy, Clone, Debug, PartialEq, Eq)]
+pub enum PlaceholderKind {
+    Loading,
+    Empty,
+    Error,
+}
+
+impl FirewallRuleEntry {
+    fn rule(&self) -> Option<&pve_api_types::FirewallRule> {
+        match self {
+            FirewallRuleEntry::Rule { rule, .. } => Some(rule),
+            FirewallRuleEntry::Root | FirewallRuleEntry::Placeholder { .. } => None,
+        }
+    }
+
+    fn placeholder_kind(&self) -> Option<PlaceholderKind> {
+        match self {
+            FirewallRuleEntry::Placeholder { kind, .. } => Some(*kind),
+            FirewallRuleEntry::Root | FirewallRuleEntry::Rule { .. } => None,
+        }
+    }
+
+    fn is_child_row(&self) -> bool {
+        matches!(self, FirewallRuleEntry::Placeholder { .. })
+            || matches!(
+                self,
+                FirewallRuleEntry::Rule {
+                    parent_pos: Some(_),
+                    ..
+                }
+            )
+    }
+
+    fn is_group_row(&self) -> bool {
+        matches!(
+            self,
+            FirewallRuleEntry::Rule { rule, parent_pos: None } if rule.ty == "group"
+        )
+    }
+}
+
+impl ExtractPrimaryKey for FirewallRuleEntry {
+    fn extract_key(&self) -> Key {
+        match self {
+            FirewallRuleEntry::Root => Key::from("root"),
+            FirewallRuleEntry::Placeholder { kind, parent_pos } => {
+                let kind = match kind {
+                    PlaceholderKind::Loading => "loading",
+                    PlaceholderKind::Empty => "empty",
+                    PlaceholderKind::Error => "error",
+                };
+                Key::from(format!("group-{parent_pos}-__{kind}__"))
+            }
+            FirewallRuleEntry::Rule {
+                rule, parent_pos, ..
+            } => {
+                if let Some(parent_pos) = parent_pos {
+                    Key::from(format!("group-{parent_pos}-{}", rule.pos))
+                } else {
+                    Key::from(format!("top-{}", rule.pos))
+                }
+            }
+        }
+    }
+}
+
 pub enum FirewallMsg {
     DataChange,
+    ToggleGroup(String, i64),
+    GroupRulesLoaded(
+        String,
+        i64,
+        Result<Vec<pve_api_types::FirewallRule>, anyhow::Error>,
+    ),
+}
+
+#[derive(Clone, Debug)]
+enum GroupLoadState {
+    Loading,
+    Loaded(Vec<pve_api_types::FirewallRule>),
+    Error,
 }
 
 #[doc(hidden)]
 pub struct ProxmoxFirewallRules {
-    store: Store<pve_api_types::FirewallRule>,
+    store: TreeStore<FirewallRuleEntry>,
     loader: Loader<Vec<pve_api_types::FirewallRule>>,
     _listener: SharedStateObserver<LoaderState<Vec<pve_api_types::FirewallRule>>>,
-    columns: Rc<Vec<DataTableHeader<pve_api_types::FirewallRule>>>,
+    columns: Rc<Vec<DataTableHeader<FirewallRuleEntry>>>,
+    expanded_groups: HashSet<i64>,
+    group_state: HashMap<String, GroupLoadState>,
+    base_rules: Vec<pve_api_types::FirewallRule>,
 }
 
 fn pill(text: impl Into<AttrValue>) -> Container {
@@ -148,50 +248,201 @@ fn format_firewall_rule(rule: &pve_api_types::FirewallRule) -> Html {
         .collect::<Html>()
 }
 
+impl Default for FirewallRuleEntry {
+    fn default() -> Self {
+        Self::Root
+    }
+}
+
 impl ProxmoxFirewallRules {
-    fn update_data(&mut self) {
-        if let Some(Ok(data)) = &self.loader.read().data {
-            self.store.set_data((**data).clone());
+    fn rebuild_tree(&mut self) {
+        let mut tree = SlabTree::new();
+        let mut root = tree.set_root(FirewallRuleEntry::default());
+
+        for rule in self.base_rules.iter() {
+            let is_group = rule.ty == "group";
+            let parent_pos = rule.pos;
+            let group_name = rule.action.as_str();
+
+            let entry = FirewallRuleEntry::Rule {
+                rule: rule.clone(),
+                parent_pos: None,
+            };
+
+            let mut node = root.append(entry);
+
+            if is_group && self.expanded_groups.contains(&parent_pos) {
+                node.set_expanded(true);
+
+                match self.group_state.get(group_name) {
+                    Some(GroupLoadState::Loaded(rules)) => {
+                        if rules.is_empty() {
+                            node.append(Self::placeholder_entry(
+                                PlaceholderKind::Empty,
+                                parent_pos,
+                            ));
+                        } else {
+                            for rule in rules.iter().cloned() {
+                                node.append(FirewallRuleEntry::Rule {
+                                    rule,
+                                    parent_pos: Some(parent_pos),
+                                });
+                            }
+                        }
+                    }
+                    Some(GroupLoadState::Error) => {
+                        node.append(Self::placeholder_entry(PlaceholderKind::Error, parent_pos));
+                    }
+                    Some(GroupLoadState::Loading) | None => {
+                        node.append(Self::placeholder_entry(
+                            PlaceholderKind::Loading,
+                            parent_pos,
+                        ));
+                    }
+                }
+            }
         }
+
+        self.store.set_data(tree);
+    }
+
+    fn update_data(&mut self) {
+        let data = match &self.loader.read().data {
+            Some(Ok(data)) => (**data).clone(),
+            _ => return,
+        };
+
+        self.base_rules = data;
+
+        self.expanded_groups.clear();
+        self.group_state.clear();
+
+        self.rebuild_tree();
+    }
+
+    fn placeholder_entry(kind: PlaceholderKind, parent_pos: i64) -> FirewallRuleEntry {
+        FirewallRuleEntry::Placeholder { kind, parent_pos }
     }
 
-    fn build_columns() -> Rc<Vec<DataTableHeader<pve_api_types::FirewallRule>>> {
+    fn build_columns(
+        on_toggle: Callback<(String, i64)>,
+    ) -> Rc<Vec<DataTableHeader<FirewallRuleEntry>>> {
         Rc::new(vec![
             DataTableColumn::new("")
-                .width("30px")
-                .justify("right")
+                .width("28px")
+                .justify("start")
                 .show_menu(false)
                 .resizable(false)
-                .render(|rule: &pve_api_types::FirewallRule| html! {&rule.pos})
+                .render_cell(DataTableCellRenderer::new(move |args: &mut DataTableCellRenderArgs<FirewallRuleEntry>| {
+                    let on_toggle = on_toggle.clone();
+                    let record = args.record();
+                    let (is_group, group_name, group_pos) = match record {
+                        FirewallRuleEntry::Rule { rule, .. } if rule.ty == "group" => {
+                            (true, rule.action.clone(), rule.pos)
+                        }
+                        _ => (false, String::new(), 0),
+                    };
+
+                    let expander = if is_group {
+                        let caret = if args.is_expanded() {
+                            "pwt-tree-expander fa fa-fw fa-caret-down"
+                        } else {
+                            "pwt-tree-expander fa fa-fw fa-caret-right"
+                        };
+
+                        let onclick = move |event: MouseEvent| {
+                            event.stop_propagation();
+                            on_toggle.emit((group_name.clone(), group_pos));
+                        };
+
+                        html! { <i role="none" style="cursor: pointer;" class={caret} {onclick} /> }
+                    } else {
+                        html! { }
+                    };
+
+                    Row::new()
+                        .class(pwt::css::AlignItems::Baseline)
+                        .with_child(expander)
+                        .into()
+                }))
+                .into(),
+            DataTableColumn::new("")
+                .width("40px")
+                .justify("start")
+                .show_menu(false)
+                .resizable(false)
+                .render(|entry: &FirewallRuleEntry| {
+                    match entry {
+                        FirewallRuleEntry::Rule { rule, parent_pos: Some(parent_pos), .. } => {
+                            html! {
+                                <span style="font-size: 0.9em; opacity: 0.9;">
+                                    { format!("{parent_pos}.{}", rule.pos) }
+                                </span>
+                            }
+                        }
+                        FirewallRuleEntry::Rule { rule, .. } => html! { {rule.pos} },
+                        FirewallRuleEntry::Root | FirewallRuleEntry::Placeholder { .. } => "".into(),
+                    }
+                })
                 .into(),
             DataTableColumn::new(tr!("On"))
                 .width("40px")
                 .justify("center")
                 .resizable(false)
-                .render(
-                    |rule: &pve_api_types::FirewallRule| match rule.enable {
+                .render(|entry: &FirewallRuleEntry| {
+                    let Some(rule) = entry.rule() else {
+                        return "".into();
+                    };
+
+                    match rule.enable {
                         Some(1) => Fa::new("check").into(),
                         Some(0) | None => Fa::new("minus").into(),
                         _ => "-".into(),
-                    },
-                )
+                    }
+                })
                 .into(),
             DataTableColumn::new(tr!("Type"))
                 .width("80px")
-                .render(|rule: &pve_api_types::FirewallRule| html! {&rule.ty})
+                .render(|entry: &FirewallRuleEntry| match entry.rule() {
+                    Some(rule) => html! {&rule.ty},
+                    None => html! {""},
+                })
                 .into(),
             DataTableColumn::new(tr!("Action"))
                 .width("100px")
-                .render(|rule: &pve_api_types::FirewallRule| html! {&rule.action})
+                .render(|entry: &FirewallRuleEntry| match entry {
+                    FirewallRuleEntry::Rule { rule, .. } => html! {&rule.action},
+                    FirewallRuleEntry::Root | FirewallRuleEntry::Placeholder { .. } => "".into(),
+                })
                 .into(),
             DataTableColumn::new(tr!("Rule"))
                 .flex(1)
-                .render(|rule: &pve_api_types::FirewallRule| format_firewall_rule(rule))
+                .render(|entry: &FirewallRuleEntry| {
+                    if let Some(kind) = entry.placeholder_kind() {
+                        let text = match kind {
+                            PlaceholderKind::Loading => tr!("Loading…"),
+                            PlaceholderKind::Empty => tr!("No rules in this group"),
+                            PlaceholderKind::Error => {
+                                format!(
+                                    "{} ({})",
+                                    tr!("Failed to load group rules"),
+                                    tr!("collapse/expand to retry")
+                                )
+                            }
+                        };
+                        return html! { <span>{text}</span> };
+                    }
+                    match entry.rule() {
+                        Some(rule) => format_firewall_rule(rule),
+                        None => "".into(),
+                    }
+                })
                 .into(),
             DataTableColumn::new(tr!("Comment"))
                 .width("150px")
-                .render(|rule: &pve_api_types::FirewallRule| {
-                    rule.comment.as_deref().unwrap_or("-").into()
+                .render(|entry: &FirewallRuleEntry| match entry.rule() {
+                    Some(rule) => rule.comment.as_deref().unwrap_or("-").into(),
+                    None => "".into(),
                 })
                 .into(),
         ])
@@ -207,9 +458,7 @@ impl Component for ProxmoxFirewallRules {
 
         let url: AttrValue = props.context.rules_url().into();
 
-        let store = Store::with_extract_key(|item: &pve_api_types::FirewallRule| {
-            Key::from(item.pos.to_string())
-        });
+        let store = TreeStore::new().view_root(false);
 
         let loader = Loader::new().loader({
             let url = url.clone();
@@ -224,12 +473,20 @@ impl Component for ProxmoxFirewallRules {
         loader.load();
 
         let mut me = Self {
-            store,
+            store: store.clone(),
             loader,
             _listener,
-            columns: Self::build_columns(),
+            columns: Rc::new(Vec::new()), // Initial empty columns, will be set below
+            expanded_groups: HashSet::new(),
+            group_state: HashMap::new(),
+            base_rules: Vec::new(),
         };
 
+        me.columns = Self::build_columns(
+            ctx.link()
+                .callback(|(group, pos)| FirewallMsg::ToggleGroup(group, pos)),
+        );
+
         me.update_data();
         me
     }
@@ -241,12 +498,56 @@ impl Component for ProxmoxFirewallRules {
         true
     }
 
-    fn update(&mut self, _ctx: &Context<Self>, msg: Self::Message) -> bool {
+    fn update(&mut self, ctx: &Context<Self>, msg: Self::Message) -> bool {
         match msg {
             FirewallMsg::DataChange => {
                 self.update_data();
                 true
             }
+            FirewallMsg::ToggleGroup(group_name, group_pos) => {
+                if self.expanded_groups.contains(&group_pos) {
+                    self.expanded_groups.remove(&group_pos);
+                    self.rebuild_tree();
+                    return true;
+                }
+
+                self.expanded_groups.insert(group_pos);
+
+                let should_load = match self.group_state.get(&group_name) {
+                    Some(GroupLoadState::Loaded(_)) | Some(GroupLoadState::Loading) => false,
+                    Some(GroupLoadState::Error) | None => true,
+                };
+
+                if should_load {
+                    self.group_state
+                        .insert(group_name.clone(), GroupLoadState::Loading);
+                    let url = ctx.props().context.group_rules_url(&group_name);
+                    ctx.link().send_future(async move {
+                        let result = crate::http_get(url, None).await;
+                        FirewallMsg::GroupRulesLoaded(group_name, group_pos, result)
+                    });
+                }
+
+                self.rebuild_tree();
+                true
+            }
+            FirewallMsg::GroupRulesLoaded(group_name, group_pos, result) => {
+                match result {
+                    Ok(mut rules) => {
+                        rules.sort_by_key(|r| r.pos);
+                        self.group_state
+                            .insert(group_name, GroupLoadState::Loaded(rules));
+                    }
+                    Err(err) => {
+                        log::warn!(
+                            "failed to load firewall security group rules for {group_pos}: {err:#}"
+                        );
+                        self.group_state.insert(group_name, GroupLoadState::Error);
+                    }
+                }
+                self.rebuild_tree();
+                true
+            }
         }
     }
 
@@ -261,6 +562,35 @@ impl Component for ProxmoxFirewallRules {
                 DataTable::new(self.columns.clone(), self.store.clone())
                     .show_header(true)
                     .striped(true)
+                    .row_render_callback({
+                        let store = self.store.clone();
+                        move |args: &mut pwt::widget::data_table::DataTableRowRenderArgs<
+                            FirewallRuleEntry,
+                        >| {
+                            let mut style = String::new();
+
+                            if args.record().is_child_row() {
+                                style.push_str(
+                                    "background-color: var(--pwt-color-neutral-container);",
+                                );
+                            }
+
+                            if args.record().is_group_row() {
+                                style.push_str("font-weight: 600;");
+                                if let Some(node) = store.read().lookup_node(args.key()) {
+                                    if node.expanded() {
+                                        style.push_str(
+                                            "box-shadow: inset 0 -2px 0 var(--pwt-color-border);",
+                                        );
+                                    }
+                                }
+                            }
+
+                            if !style.is_empty() {
+                                args.set_attribute("style", Some(style));
+                            }
+                        }
+                    })
                     .into()
             }
         })
-- 
2.47.3



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel

^ permalink raw reply	[flat|nested] 5+ messages in thread

end of thread, other threads:[~2025-12-17 16:17 UTC | newest]

Thread overview: 5+ messages (download: mbox.gz / follow: Atom feed)
-- links below jump to the message on this page --
2025-12-17 16:17 [pdm-devel] [PATCH proxmox{, -datacenter-manager, -yew-comp} v2 0/4] make security groups expandable in firewall rules list Hannes Laimer
2025-12-17 16:17 ` [pdm-devel] [PATCH proxmox v2 1/2] pve-api-types: add security group GET endpoints Hannes Laimer
2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox v2 2/2] pve-api-types: regenerate Hannes Laimer
2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox-datacenter-manager v2 1/1] api: firewall: add pve firewall security group GET endpoints Hannes Laimer
2025-12-17 16:18 ` [pdm-devel] [PATCH proxmox-yew-comp v2 1/1] firewall: rules: make security group entries expandable Hannes Laimer

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