all lists on 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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal