From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id DBB321FF17E for ; Thu, 30 Oct 2025 15:34:15 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 6AF1D24554; Thu, 30 Oct 2025 15:34:50 +0100 (CET) From: Hannes Laimer To: pdm-devel@lists.proxmox.com Date: Thu, 30 Oct 2025 15:34:01 +0100 Message-ID: <20251030143406.193744-9-h.laimer@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251030143406.193744-1-h.laimer@proxmox.com> References: <20251030143406.193744-1-h.laimer@proxmox.com> MIME-Version: 1.0 X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1761834836149 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.042 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment RCVD_IN_MSPIKE_H2 0.001 Average reputation (+2) SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: [pdm-devel] [PATCH proxmox-yew-comp 3/4] firewall: add options edit form X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" This also includes the log-ratelimit field, its value is a property string. Signed-off-by: Hannes Laimer --- src/firewall/log_ratelimit_field.rs | 310 +++++++++++++++++++++ src/firewall/mod.rs | 6 + src/firewall/options_edit.rs | 404 ++++++++++++++++++++++++++++ src/lib.rs | 2 +- 4 files changed, 721 insertions(+), 1 deletion(-) create mode 100644 src/firewall/log_ratelimit_field.rs create mode 100644 src/firewall/options_edit.rs diff --git a/src/firewall/log_ratelimit_field.rs b/src/firewall/log_ratelimit_field.rs new file mode 100644 index 0000000..c9daeed --- /dev/null +++ b/src/firewall/log_ratelimit_field.rs @@ -0,0 +1,310 @@ +use anyhow::Error; +use proxmox_schema::ApiType; +use serde_json::Value; +use yew::html::{IntoEventCallback, IntoPropValue}; + +use pwt::prelude::*; +use pwt::props::FieldBuilder; +use pwt::widget::form::{ + Checkbox, Combobox, ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldState, + Number, +}; +use pwt::widget::{InputPanel, Row}; + +use pwt::props::WidgetBuilder; +use pwt_macros::{builder, widget}; + +use crate::SchemaValidation; + +const TIME_UNITS: &[&str] = &["second", "minute", "hour", "day"]; + +#[widget(comp=RateFieldImpl, @input)] +#[derive(Clone, Properties, PartialEq)] +#[builder] +pub struct RateField { + #[builder(IntoPropValue, into_prop_value)] + #[prop_or_default] + pub value: Option, + + #[builder_cb(IntoEventCallback, into_event_callback, String)] + #[prop_or_default] + pub on_input: Option>, +} + +impl Default for RateField { + fn default() -> Self { + Self::new() + } +} + +impl RateField { + pub fn new() -> Self { + yew::props!(Self {}) + } +} + +pub enum RateMsg { + ChangeNumber(Option), + ChangeUnit(String), +} + +pub struct RateFieldImpl { + number: Option, + unit: String, +} + +impl yew::Component for RateFieldImpl { + type Message = RateMsg; + type Properties = RateField; + + fn create(ctx: &yew::Context) -> Self { + let mut me = Self { + number: None, + unit: "second".to_string(), + }; + me.parse_value(&ctx.props().value); + me + } + + fn update(&mut self, ctx: &yew::Context, msg: Self::Message) -> bool { + match msg { + RateMsg::ChangeNumber(number) => { + self.number = number; + } + RateMsg::ChangeUnit(unit) => self.unit = unit, + } + + let new_value_str = if let Some(num) = self.number { + format!("{}/{}", num, self.unit) + } else { + String::new() + }; + + if let Some(callback) = &ctx.props().on_input { + callback.emit(new_value_str); + } + + true + } + + fn changed(&mut self, ctx: &yew::Context, _old_props: &Self::Properties) -> bool { + self.parse_value(&ctx.props().value); + true + } + + fn view(&self, ctx: &yew::Context) -> Html { + let is_empty = self.number.is_none(); + let number_value = self.number.map(|n| n.to_string()).unwrap_or_default(); + + let units: Vec = TIME_UNITS.iter().map(|&u| AttrValue::from(u)).collect(); + + Row::new() + .style("align-items", "center") + .gap(1) + .with_child( + Number::::new() + .key("rate_number") + .value(number_value) + .placeholder("1") + .min(1) + .on_change(ctx.link().callback(|result: Option>| { + RateMsg::ChangeNumber(result.and_then(|r| r.ok())) + })), + ) + .with_child("/") + .with_child( + Combobox::new() + .key("rate_unit") + .items(std::rc::Rc::new(units)) + .value(self.unit.clone()) + .disabled(is_empty) + .required(true) + .on_change(ctx.link().callback(RateMsg::ChangeUnit)), + ) + .into() + } +} + +impl RateFieldImpl { + fn parse_value(&mut self, value: &Option) { + self.number = None; + self.unit = "second".to_string(); + + if let Some(v) = value { + if !v.is_empty() { + if let Some((num, unit)) = v.split_once('/') { + self.number = num.parse::().ok(); + self.unit = unit.to_string(); + } + } + } + } +} + +#[widget(comp=ManagedFieldMaster, @input)] +#[derive(Clone, Properties, PartialEq)] +#[builder] +pub struct LogRatelimitField { + #[builder(IntoPropValue, into_prop_value)] + #[prop_or_default] + pub default: Option, +} + +impl Default for LogRatelimitField { + fn default() -> Self { + Self::new() + } +} + +impl LogRatelimitField { + pub fn new() -> Self { + yew::props!(Self {}) + } +} + +pub enum LogRatelimitMsg { + Enable(bool), + Rate(String), + Burst(Option), +} + +pub struct LogRatelimitFieldImpl { + enable: bool, + rate: String, + burst: Option, +} + +impl ManagedField for LogRatelimitFieldImpl { + type Message = LogRatelimitMsg; + type Properties = LogRatelimitField; + type ValidateClosure = (); + + fn validation_args(_props: &Self::Properties) -> Self::ValidateClosure {} + + fn validator(_props: &Self::ValidateClosure, value: &Value) -> Result { + Ok(value.clone()) + } + + fn setup(props: &Self::Properties) -> ManagedFieldState { + let value = Value::Null; + let default = match &props.default { + Some(d) => Value::String(d.to_string()), + None => Value::String(String::new()), + }; + ManagedFieldState::new(value, default) + } + + fn value_changed(&mut self, ctx: &ManagedFieldContext) { + let state = ctx.state(); + + // Initialize with API defaults (enable=true is the API default) + // When the property string is empty, rate and burst are unset + self.enable = true; + self.rate = String::new(); + self.burst = None; + + // If value is Null, use the default instead + let value_to_parse = match &state.value { + Value::Null => &state.default, + other => other, + }; + + if let Value::String(v) = value_to_parse { + if !v.is_empty() { + match pve_api_types::ClusterFirewallOptionsLogRatelimit::API_SCHEMA + .parse_property_string(v) + { + Ok(parsed) => { + match serde_json::from_value::< + pve_api_types::ClusterFirewallOptionsLogRatelimit, + >(parsed) + { + Ok(ratelimit) => { + self.enable = ratelimit.enable; + if let Some(rate) = ratelimit.rate { + self.rate = rate.clone(); + } + self.burst = ratelimit.burst; + } + Err(e) => { + log::error!("Failed to parse log_ratelimit value: {:?}", e); + } + } + } + Err(e) => { + log::error!( + "Failed to parse log_ratelimit property string '{}': {:?}", + v, + e + ); + } + } + } + } + } + + fn create(ctx: &ManagedFieldContext) -> Self { + let mut me = Self { + enable: true, + rate: String::new(), + burst: None, + }; + me.value_changed(ctx); + me + } + + fn update(&mut self, ctx: &ManagedFieldContext, msg: Self::Message) -> bool { + match msg { + LogRatelimitMsg::Enable(enable) => self.enable = enable, + LogRatelimitMsg::Rate(rate) => self.rate = rate, + LogRatelimitMsg::Burst(burst_value) => self.burst = burst_value, + } + + let mut parts = Vec::new(); + parts.push(format!("enable={}", if self.enable { 1 } else { 0 })); + if !self.rate.is_empty() { + parts.push(format!("rate={}", self.rate)); + } + if let Some(burst) = self.burst { + parts.push(format!("burst={}", burst)); + } + let property_string = parts.join(","); + let new_value = Value::String(property_string); + + ctx.link().update_value(new_value); + true + } + + fn view(&self, ctx: &ManagedFieldContext) -> Html { + let props = ctx.props(); + let base_schema = &pve_api_types::ClusterFirewallOptionsLogRatelimit::API_SCHEMA; + + InputPanel::new() + .with_std_props(&props.std_props) + .with_field( + tr!("Enable"), + Checkbox::new() + .key("enable") + .checked(self.enable) + .on_change(ctx.link().callback(LogRatelimitMsg::Enable)), + ) + .with_field( + tr!("Rate"), + RateField::new() + .key("rate") + .value(self.rate.clone()) + .on_input(ctx.link().callback(LogRatelimitMsg::Rate)), + ) + .with_field( + tr!("Burst"), + Number::::new() + .key("burst") + .value(self.burst.map(|b| b.to_string())) + .on_change(ctx.link().callback(|result: Option>| { + LogRatelimitMsg::Burst(result.and_then(|r| r.ok())) + })) + .schema(crate::form::get_field_schema(base_schema, vec!["burst"])), + ) + .into() + } +} diff --git a/src/firewall/mod.rs b/src/firewall/mod.rs index 49dcf23..379b958 100644 --- a/src/firewall/mod.rs +++ b/src/firewall/mod.rs @@ -1,2 +1,8 @@ mod context; pub use context::FirewallContext; + +mod options_edit; +pub use options_edit::EditFirewallOptions; + +mod log_ratelimit_field; +pub use log_ratelimit_field::LogRatelimitField; diff --git a/src/firewall/options_edit.rs b/src/firewall/options_edit.rs new file mode 100644 index 0000000..6b0c4e2 --- /dev/null +++ b/src/firewall/options_edit.rs @@ -0,0 +1,404 @@ +use std::rc::Rc; + +use anyhow::Error; +use proxmox_schema::ApiType; +use pve_api_types::{ClusterFirewallOptions, GuestFirewallOptions, NodeFirewallOptions}; +use serde_json::Value; +use yew::html::{IntoEventCallback, IntoPropValue}; +use yew::virtual_dom::{VComp, VNode}; + +use pwt::prelude::*; +use pwt::widget::form::{Checkbox, Combobox, FormContext, Number}; +use pwt::widget::InputPanel; + +use pwt_macros::builder; + +use crate::{form::delete_empty_values, ApiLoadCallback, EditWindow}; + +use super::{context::FirewallContext, LogRatelimitField}; + +fn enum_items_from_schema(name: &str) -> Vec { + let s = crate::form::get_field_schema(&T::API_SCHEMA, vec![name]); + crate::form::enum_items_from_schema(s) +} + +fn placeholder_from_schema(name: &str) -> String { + let s = crate::form::get_field_schema(&T::API_SCHEMA, vec![name]); + crate::form::placeholder_from_schema(s) +} + +fn create_firewall_options_loader(url: AttrValue, transform_fn: F) -> ApiLoadCallback +where + F: Fn(&mut serde_json::Map) + Clone + 'static, +{ + ApiLoadCallback::new(move || { + let url = url.clone(); + let transform_fn = transform_fn.clone(); + async move { + let mut resp = crate::http_get_full(url.to_string(), None).await?; + if let serde_json::Value::Object(ref mut map) = resp.data { + transform_fn(map); + } + Ok::<_, anyhow::Error>(resp) + } + }) +} + +async fn update_firewall_options( + form_ctx: FormContext, + url: AttrValue, + fields: &[&str], + transform_fn: Option)>, +) -> Result<(), Error> { + let mut data = form_ctx.get_submit_data(); + + if let (Some(transform), serde_json::Value::Object(ref mut map)) = (transform_fn, &mut data) { + transform(map); + } + + let data = delete_empty_values(&data, fields, true); + + crate::http_put(&url.to_string(), Some(data)).await +} + +#[derive(Clone, PartialEq, Properties)] +#[builder] +pub struct EditFirewallOptions { + #[builder(IntoPropValue, into_prop_value)] + pub context: FirewallContext, + + #[builder_cb(IntoEventCallback, into_event_callback, ())] + #[prop_or_default] + pub on_close: Option>, +} + +impl EditFirewallOptions { + pub fn cluster(remote: impl Into) -> Self { + yew::props!(Self { + context: FirewallContext::cluster(remote), + }) + } + + pub fn node(remote: impl Into, node: impl Into) -> Self { + yew::props!(Self { + context: FirewallContext::node(remote, node), + }) + } + + pub fn guest( + remote: impl Into, + node: impl Into, + vmid: u64, + vmtype: impl Into, + ) -> Self { + yew::props!(Self { + context: FirewallContext::guest(remote, node, vmid, vmtype), + }) + } +} + +pub struct ProxmoxEditFirewallOptions { + loader: Option>, +} + +impl Component for ProxmoxEditFirewallOptions { + type Message = (); + type Properties = EditFirewallOptions; + + fn create(ctx: &Context) -> Self { + let props = ctx.props(); + let url: AttrValue = props.context.options_url().into(); + + let loader = if !url.is_empty() { + Some(create_firewall_options_loader(url, |map| { + // Convert enable field from u64 to bool for cluster firewall + if let Some(enable_num) = map.get("enable").and_then(|v| v.as_u64()) { + map.insert("enable".into(), serde_json::Value::Bool(enable_num != 0)); + } + })) + } else { + None + }; + + Self { loader } + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + let url: AttrValue = props.context.options_url().into(); + + type ContextConfig<'a> = ( + String, + fn(&FormContext) -> Html, + &'a [&'a str], + Option)>, + ); + let (title, renderer, fields, transform_fn): ContextConfig<'_> = match &props.context { + FirewallContext::Cluster { .. } => ( + props.context.title(&tr!("Edit Cluster Firewall")), + edit_cluster_firewall_input_panel, + &[ + "enable", + "ebtables", + "policy_in", + "policy_out", + "policy_forward", + "log_ratelimit", + ], + Some(|map: &mut serde_json::Map| { + if let Some(enable) = map.get("enable").and_then(|v| v.as_bool()) { + map.insert("enable".into(), Value::from(if enable { 1 } else { 0 })); + } + }), + ), + FirewallContext::Node { .. } => ( + props.context.title(&tr!("Edit Node Firewall")), + edit_node_firewall_input_panel, + &[ + "enable", + "ndp", + "log_nf_conntrack", + "nf_conntrack_allow_invalid", + "log_level_in", + "log_level_out", + "log_level_forward", + "nf_conntrack_max", + "nf_conntrack_tcp_timeout_established", + "nf_conntrack_tcp_timeout_syn_recv", + ], + None, + ), + FirewallContext::Guest { .. } => ( + props.context.title(&tr!("Edit Guest Firewall")), + edit_guest_firewall_input_panel, + &[ + "enable", + "dhcp", + "ipfilter", + "macfilter", + "ndp", + "radv", + "policy_in", + "policy_out", + "log_level_in", + "log_level_out", + ], + None, + ), + }; + + EditWindow::new(title) + .loader(self.loader.clone()) + .on_close(props.on_close.clone()) + .on_done(props.on_close.clone()) + .renderer(renderer) + .on_submit({ + let url = url.clone(); + move |form_ctx: FormContext| { + let url = url.clone(); + let fields = fields.to_vec(); + async move { update_firewall_options(form_ctx, url, &fields, transform_fn).await } + } + }) + .into() + } +} + +fn edit_cluster_firewall_input_panel(_form_ctx: &FormContext) -> Html { + InputPanel::new() + .padding(4) + .with_large_field( + tr!("Enable Firewall"), + Checkbox::new().name("enable").key("enable"), + ) + .with_large_field( + tr!("Enable ebtables"), + Checkbox::new().name("ebtables").key("ebtables"), + ) + .with_field( + tr!("Input Policy"), + Combobox::new() + .name("policy_in") + .key("policy_in") + .placeholder(placeholder_from_schema::( + "policy_in", + )) + .items(enum_items_from_schema::("policy_in").into()), + ) + .with_field( + tr!("Output Policy"), + Combobox::new() + .name("policy_out") + .key("policy_out") + .placeholder(placeholder_from_schema::( + "policy_out", + )) + .items(enum_items_from_schema::("policy_out").into()), + ) + .with_field( + tr!("Forward Policy"), + Combobox::new() + .name("policy_forward") + .key("policy_forward") + .placeholder(placeholder_from_schema::( + "policy_forward", + )) + .items(enum_items_from_schema::("policy_forward").into()), + ) + .with_large_field( + tr!("Log Rate Limiting"), + LogRatelimitField::new() + .name("log_ratelimit") + .key("log_ratelimit"), + ) + .into() +} + +impl From for VNode { + fn from(val: EditFirewallOptions) -> Self { + let comp = VComp::new::(Rc::new(val), None); + VNode::from(comp) + } +} + +fn edit_guest_firewall_input_panel(_form_ctx: &FormContext) -> Html { + InputPanel::new() + .padding(4) + .with_field( + tr!("Enable Firewall"), + Checkbox::new().name("enable").key("enable"), + ) + .with_right_field(tr!("Enable DHCP"), Checkbox::new().name("dhcp").key("dhcp")) + .with_field( + tr!("Enable IP Filter"), + Checkbox::new().name("ipfilter").key("ipfilter"), + ) + .with_right_field( + tr!("Enable MAC Filter"), + Checkbox::new().name("macfilter").key("macfilter"), + ) + .with_field(tr!("Enable NDP"), Checkbox::new().name("ndp").key("ndp")) + .with_right_field( + tr!("Allow Router Advertisement"), + Checkbox::new().name("radv").key("radv"), + ) + .with_field( + tr!("Input Policy"), + Combobox::new() + .name("policy_in") + .key("policy_in") + .placeholder(placeholder_from_schema::("policy_in")) + .items(enum_items_from_schema::("policy_in").into()), + ) + .with_right_field( + tr!("Output Policy"), + Combobox::new() + .name("policy_out") + .key("policy_out") + .placeholder(placeholder_from_schema::( + "policy_out", + )) + .items(enum_items_from_schema::("policy_out").into()), + ) + .with_field( + tr!("Log Level In"), + Combobox::new() + .name("log_level_in") + .key("log_level_in") + .placeholder(placeholder_from_schema::( + "log_level_in", + )) + .items(enum_items_from_schema::("log_level_in").into()), + ) + .with_right_field( + tr!("Log Level Out"), + Combobox::new() + .name("log_level_out") + .key("log_level_out") + .placeholder(placeholder_from_schema::( + "log_level_out", + )) + .items(enum_items_from_schema::("log_level_out").into()), + ) + .into() +} + +fn edit_node_firewall_input_panel(_form_ctx: &FormContext) -> Html { + InputPanel::new() + .padding(4) + .with_field( + tr!("Enable Firewall"), + Checkbox::new().name("enable").key("enable"), + ) + .with_right_field(tr!("Enable NDP"), Checkbox::new().name("ndp").key("ndp")) + .with_field( + tr!("Log Connection Tracking"), + Checkbox::new() + .name("log_nf_conntrack") + .key("log_nf_conntrack"), + ) + .with_right_field( + tr!("Allow Invalid Connections"), + Checkbox::new() + .name("nf_conntrack_allow_invalid") + .key("nf_conntrack_allow_invalid"), + ) + .with_field( + tr!("Log Level In"), + Combobox::new() + .name("log_level_in") + .key("log_level_in") + .placeholder(placeholder_from_schema::( + "log_level_in", + )) + .items(enum_items_from_schema::("log_level_in").into()), + ) + .with_right_field( + tr!("Log Level Out"), + Combobox::new() + .name("log_level_out") + .key("log_level_out") + .placeholder(placeholder_from_schema::( + "log_level_out", + )) + .items(enum_items_from_schema::("log_level_out").into()), + ) + .with_field( + tr!("Log Level Forward"), + Combobox::new() + .name("log_level_forward") + .key("log_level_forward") + .placeholder(placeholder_from_schema::( + "log_level_forward", + )) + .items(enum_items_from_schema::("log_level_forward").into()), + ) + .with_right_field( + tr!("Connection Tracking Max"), + Number::::new() + .name("nf_conntrack_max") + .key("nf_conntrack_max") + .placeholder(placeholder_from_schema::( + "nf_conntrack_max", + )), + ) + .with_field( + tr!("TCP Timeout Established"), + Number::::new() + .name("nf_conntrack_tcp_timeout_established") + .key("nf_conntrack_tcp_timeout_established") + .placeholder(placeholder_from_schema::( + "nf_conntrack_tcp_timeout_established", + )), + ) + .with_right_field( + tr!("TCP Timeout SYN Recv"), + Number::::new() + .name("nf_conntrack_tcp_timeout_syn_recv") + .key("nf_conntrack_tcp_timeout_syn_recv") + .placeholder(placeholder_from_schema::( + "nf_conntrack_tcp_timeout_syn_recv", + )), + ) + .into() +} diff --git a/src/lib.rs b/src/lib.rs index e2e2721..852d65d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -130,7 +130,7 @@ mod rrd_timeframe_selector; pub use rrd_timeframe_selector::{RRDTimeframe, RRDTimeframeSelector}; mod firewall; -pub use firewall::FirewallContext; +pub use firewall::{EditFirewallOptions, FirewallContext}; mod running_tasks; pub use running_tasks::{ProxmoxRunningTasks, RunningTasks}; -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel