From 52023c1bbdb492cc63cdeac5ce895fb978dff7b0 Mon Sep 17 00:00:00 2001 From: MorganaFuture Date: Wed, 27 May 2026 22:05:39 +0300 Subject: [PATCH 1/2] Expose K/V properties on Action types MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add an ActionProperties trait (no-op defaults, so nothing else has to change) and require it on the four Action super-traits. RuleDump now carries a Vec so dump-layer callers can read each action's config; bump API_VERSION to 41. Hook up the existing engine and oxide-vpc actions. The motivating one is EncapAction, which now exposes vni and phys_ip_src — so an operator can finally tell which VNI a port is encapsulating into. --- crates/opte-api/src/cmd.rs | 15 ++++ crates/opte-api/src/lib.rs | 2 +- lib/opte/src/engine/dhcp.rs | 16 ++++ lib/opte/src/engine/dhcpv6/mod.rs | 19 +++++ lib/opte/src/engine/icmp/v4.rs | 16 ++++ lib/opte/src/engine/icmp/v6.rs | 49 +++++++++++ lib/opte/src/engine/mod.rs | 1 + lib/opte/src/engine/nat.rs | 30 +++++++ lib/opte/src/engine/props.rs | 108 ++++++++++++++++++++++++ lib/opte/src/engine/rule.rs | 40 ++++++++- lib/opte/src/engine/snat.rs | 17 ++++ lib/oxide-vpc/src/engine/gateway/mod.rs | 15 ++++ lib/oxide-vpc/src/engine/overlay.rs | 60 +++++++++++++ lib/oxide-vpc/src/engine/router.rs | 14 +++ 14 files changed, 397 insertions(+), 5 deletions(-) create mode 100644 lib/opte/src/engine/props.rs diff --git a/crates/opte-api/src/cmd.rs b/crates/opte-api/src/cmd.rs index 675a5a26..e3cbbd89 100644 --- a/crates/opte-api/src/cmd.rs +++ b/crates/opte-api/src/cmd.rs @@ -487,4 +487,19 @@ pub struct RuleDump { pub predicates: Vec, pub data_predicates: Vec, pub action: String, + /// Read-only diagnostic properties exposed by the rule's [`Action`], + /// modeled after `dladm show-linkprop`. Populated by implementations + /// of the `ActionProperties` trait. + pub action_properties: Vec, +} + +/// A single key/value diagnostic property of an [`Action`] (or another +/// `NetworkImpl`-defined object). +/// +/// Properties are immutable once the owning object is constructed and +/// are intended for human and machine readers (e.g. `opteadm show-prop`). +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +pub struct ActionProperty { + pub name: String, + pub value: String, } diff --git a/crates/opte-api/src/lib.rs b/crates/opte-api/src/lib.rs index 5c319116..0ea97614 100644 --- a/crates/opte-api/src/lib.rs +++ b/crates/opte-api/src/lib.rs @@ -51,7 +51,7 @@ pub use ulp::*; /// /// We rely on CI and the check-api-version.sh script to verify that /// this number is incremented anytime the oxide-api code changes. -pub const API_VERSION: u64 = 40; +pub const API_VERSION: u64 = 41; /// Major version of the OPTE package. pub const MAJOR_VERSION: u64 = 0; diff --git a/lib/opte/src/engine/dhcp.rs b/lib/opte/src/engine/dhcp.rs index 3a80ad0c..24b342c6 100644 --- a/lib/opte/src/engine/dhcp.rs +++ b/lib/opte/src/engine/dhcp.rs @@ -433,6 +433,22 @@ impl Display for DhcpAction { } } +impl crate::engine::props::ActionProperties for DhcpAction { + fn property_names(&self) -> &'static [&'static str] { + &["client_mac", "client_ip", "gw_mac", "gw_ip", "reply_type"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "client_mac" => Some(self.client_mac.to_string()), + "client_ip" => Some(self.client_ip.to_string()), + "gw_mac" => Some(self.gw_mac.to_string()), + "gw_ip" => Some(self.gw_ip.to_string()), + "reply_type" => Some(self.reply_type.to_string()), + _ => None, + } + } +} + // XXX I read up just enough on DHCP to get initial lease working. // However, I imagine there could be post-lease messages between // client/server and those might be unicast, at which point these diff --git a/lib/opte/src/engine/dhcpv6/mod.rs b/lib/opte/src/engine/dhcpv6/mod.rs index 98ffa9c7..29c53bab 100644 --- a/lib/opte/src/engine/dhcpv6/mod.rs +++ b/lib/opte/src/engine/dhcpv6/mod.rs @@ -169,6 +169,25 @@ impl Display for Dhcpv6Action { } } +impl crate::engine::props::ActionProperties for Dhcpv6Action { + fn property_names(&self) -> &'static [&'static str] { + &["client_mac", "server_mac", "addresses"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "client_mac" => Some(self.client_mac.to_string()), + "server_mac" => Some(self.server_mac.to_string()), + "addresses" => Some( + self.addresses() + .map(|a| a.to_string()) + .collect::>() + .join(","), + ), + _ => None, + } + } +} + /// A lifetime describes the duration over which data such as addresses are /// valid. These are encoded in messages as a u32. #[derive(Clone, Copy, Debug, PartialEq)] diff --git a/lib/opte/src/engine/icmp/v4.rs b/lib/opte/src/engine/icmp/v4.rs index 93aa1e52..4a8af9c5 100644 --- a/lib/opte/src/engine/icmp/v4.rs +++ b/lib/opte/src/engine/icmp/v4.rs @@ -26,6 +26,22 @@ use opte::engine::Checksum as OpteCsum; pub use opte_api::ip::IcmpEchoReply; use smoltcp::wire; +impl crate::engine::props::ActionProperties for IcmpEchoReply { + fn property_names(&self) -> &'static [&'static str] { + &["echo_src_mac", "echo_src_ip", "echo_dst_mac", "echo_dst_ip"] + } + fn get_property(&self, name: &str) -> Option { + use alloc::string::ToString; + match name { + "echo_src_mac" => Some(self.echo_src_mac.to_string()), + "echo_src_ip" => Some(self.echo_src_ip.to_string()), + "echo_dst_mac" => Some(self.echo_dst_mac.to_string()), + "echo_dst_ip" => Some(self.echo_dst_ip.to_string()), + _ => None, + } + } +} + impl HairpinAction for IcmpEchoReply { fn implicit_preds(&self) -> (Vec, Vec) { let hdr_preds = vec![ diff --git a/lib/opte/src/engine/icmp/v6.rs b/lib/opte/src/engine/icmp/v6.rs index 5562c19c..3c0bada2 100644 --- a/lib/opte/src/engine/icmp/v6.rs +++ b/lib/opte/src/engine/icmp/v6.rs @@ -91,6 +91,22 @@ impl Display for MessageType { } } +impl crate::engine::props::ActionProperties for Icmpv6EchoReply { + fn property_names(&self) -> &'static [&'static str] { + &["src_mac", "src_ip", "dst_mac", "dst_ip"] + } + fn get_property(&self, name: &str) -> Option { + use alloc::string::ToString; + match name { + "src_mac" => Some(self.src_mac.to_string()), + "src_ip" => Some(self.src_ip.to_string()), + "dst_mac" => Some(self.dst_mac.to_string()), + "dst_ip" => Some(self.dst_ip.to_string()), + _ => None, + } + } +} + impl HairpinAction for Icmpv6EchoReply { fn implicit_preds(&self) -> (Vec, Vec) { let hdr_preds = vec![ @@ -199,6 +215,22 @@ impl HairpinAction for Icmpv6EchoReply { } } +impl crate::engine::props::ActionProperties for RouterAdvertisement { + fn property_names(&self) -> &'static [&'static str] { + &["src_mac", "mac", "ip", "managed_cfg"] + } + fn get_property(&self, name: &str) -> Option { + use alloc::string::ToString; + match name { + "src_mac" => Some(self.src_mac.to_string()), + "mac" => Some(self.mac.to_string()), + "ip" => Some(self.ip().to_string()), + "managed_cfg" => Some(self.managed_cfg.to_string()), + _ => None, + } + } +} + impl HairpinAction for RouterAdvertisement { fn implicit_preds(&self) -> (Vec, Vec) { const ALL_ROUTERS_MAC: MacAddr = @@ -502,6 +534,23 @@ fn construct_neighbor_advert<'a>( )) } +impl crate::engine::props::ActionProperties for NeighborAdvertisement { + fn property_names(&self) -> &'static [&'static str] { + &["src_mac", "mac", "ip", "is_router", "allow_unspec"] + } + fn get_property(&self, name: &str) -> Option { + use alloc::string::ToString; + match name { + "src_mac" => Some(self.src_mac.to_string()), + "mac" => Some(self.mac.to_string()), + "ip" => Some(self.ip().to_string()), + "is_router" => Some(self.is_router.to_string()), + "allow_unspec" => Some(self.allow_unspec.to_string()), + _ => None, + } + } +} + impl HairpinAction for NeighborAdvertisement { fn implicit_preds(&self) -> (Vec, Vec) { // The source IP must be a link-local IPv6 address, or, if diff --git a/lib/opte/src/engine/mod.rs b/lib/opte/src/engine/mod.rs index 603f51b3..c128f433 100644 --- a/lib/opte/src/engine/mod.rs +++ b/lib/opte/src/engine/mod.rs @@ -26,6 +26,7 @@ pub mod packet; pub mod parse; pub mod port; pub mod predicate; +pub mod props; pub mod rule; pub mod snat; #[macro_use] diff --git a/lib/opte/src/engine/nat.rs b/lib/opte/src/engine/nat.rs index 99e26267..8a155188 100644 --- a/lib/opte/src/engine/nat.rs +++ b/lib/opte/src/engine/nat.rs @@ -25,6 +25,7 @@ use super::port::meta::ActionMeta; use super::port::meta::ActionMetaValue; use super::predicate::DataPredicate; use super::predicate::Predicate; +use super::props::ActionProperties; use super::rule; use super::rule::ActionDesc; use super::rule::AllowOrDeny; @@ -105,6 +106,21 @@ impl fmt::Display for OutboundNat { } } +impl ActionProperties for OutboundNat { + fn property_names(&self) -> &'static [&'static str] { + &["priv_ip", "external_ips"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "priv_ip" => Some(self.priv_ip.to_string()), + "external_ips" => { + Some(self.external_ips.iter().format(",").to_string()) + } + _ => None, + } + } +} + impl StatefulAction for OutboundNat { fn gen_desc( &self, @@ -168,6 +184,18 @@ impl fmt::Display for InboundNat { } } +impl ActionProperties for InboundNat { + fn property_names(&self) -> &'static [&'static str] { + &["priv_ip"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "priv_ip" => Some(self.priv_ip.to_string()), + _ => None, + } + } +} + impl StatefulAction for InboundNat { fn gen_desc( &self, @@ -401,6 +429,8 @@ impl fmt::Display for ExternalIpTagger { } } +impl ActionProperties for ExternalIpTagger {} + impl MetaAction for ExternalIpTagger { fn implicit_preds(&self) -> (Vec, Vec) { (vec![], vec![]) diff --git a/lib/opte/src/engine/props.rs b/lib/opte/src/engine/props.rs new file mode 100644 index 00000000..7b056b21 --- /dev/null +++ b/lib/opte/src/engine/props.rs @@ -0,0 +1,108 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +// Copyright 2026 Oxide Computer Company + +//! Diagnostic property exposure for actions and other engine objects. +//! +//! Inspired by `dladm show-linkprop`, this gives operators a generic way +//! to query the immutable configuration of an action (e.g. the VNI an +//! `EncapAction` is encapsulating into) without `opteadm` needing any +//! per-action knowledge. + +pub use opte_api::ActionProperty; + +use alloc::string::ToString; +use alloc::vec::Vec; + +/// Implemented by anything that wants to expose immutable, read-only, +/// human/machine-friendly configuration to inspection tooling. +/// +/// All methods have sensible defaults so types with nothing to report +/// can simply rely on the blanket behavior — no boilerplate, no opt-in +/// derive required. +pub trait ActionProperties { + /// Names of every property this implementation can return. + /// + /// Treated as a stable contract with operators: prefer adding new + /// names over renaming existing ones. + fn property_names(&self) -> &'static [&'static str] { + &[] + } + + /// Return the value of a single named property, or `None` if the + /// implementation does not know the name. + fn get_property(&self, _name: &str) -> Option { + None + } + + /// Materialize every `(name, value)` pair this implementation + /// exposes. The default implementation walks [`Self::property_names`] + /// and calls [`Self::get_property`] for each entry. + fn properties(&self) -> Vec { + self.property_names() + .iter() + .filter_map(|name| { + self.get_property(name).map(|value| ActionProperty { + name: (*name).to_string(), + value, + }) + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use alloc::string::String; + + /// A test type that exposes a couple of properties. + struct Sample { + a: u32, + b: &'static str, + } + + impl ActionProperties for Sample { + fn property_names(&self) -> &'static [&'static str] { + &["a", "b"] + } + fn get_property(&self, name: &str) -> Option { + use alloc::string::ToString; + match name { + "a" => Some(self.a.to_string()), + "b" => Some(self.b.to_string()), + _ => None, + } + } + } + + #[test] + fn default_impl_is_empty() { + struct Empty; + impl ActionProperties for Empty {} + assert!(Empty.properties().is_empty()); + assert!(Empty.get_property("anything").is_none()); + assert!(Empty.property_names().is_empty()); + } + + #[test] + fn properties_walks_names() { + let s = Sample { a: 42, b: "hi" }; + let props = s.properties(); + assert_eq!(props.len(), 2); + assert_eq!(props[0].name, "a"); + assert_eq!(props[0].value, "42"); + assert_eq!(props[1].name, "b"); + assert_eq!(props[1].value, "hi"); + } + + #[test] + fn get_property_unknown_returns_none() { + let s = Sample { a: 0, b: "" }; + assert!(s.get_property("nope").is_none()); + assert_eq!(s.get_property("a").as_deref(), Some("0")); + } +} + diff --git a/lib/opte/src/engine/rule.rs b/lib/opte/src/engine/rule.rs index 9cc4f9c0..cd65dd59 100644 --- a/lib/opte/src/engine/rule.rs +++ b/lib/opte/src/engine/rule.rs @@ -39,6 +39,8 @@ use super::parse::ValidUlp; use super::port::meta::ActionMeta; use super::predicate::DataPredicate; use super::predicate::Predicate; +use super::props::ActionProperties; +use super::props::ActionProperty; use crate::ddi::mblk::MsgBlk; use alloc::boxed::Box; use alloc::ffi::CString; @@ -278,6 +280,18 @@ impl Display for Identity { } } +impl ActionProperties for Identity { + fn property_names(&self) -> &'static [&'static str] { + &["name"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "name" => Some(self.name.clone()), + _ => None, + } + } +} + impl StaticAction for Identity { fn gen_ht( &self, @@ -743,7 +757,7 @@ pub enum GenDescError { pub type GenDescResult = ActionResult, GenDescError>; -pub trait StatefulAction: Display + Send + Sync { +pub trait StatefulAction: Display + ActionProperties + Send + Sync { /// Generate a an [`ActionDesc`] based on the [`InnerFlowId`] and /// [`ActionMeta`]. This action may also add, remove, or modify /// metadata to communicate data to downstream actions. @@ -773,7 +787,7 @@ pub enum GenHtError { pub type GenHtResult = ActionResult; -pub trait StaticAction: Display + Send + Sync { +pub trait StaticAction: Display + ActionProperties + Send + Sync { fn gen_ht( &self, dir: Direction, @@ -796,7 +810,7 @@ pub type ModMetaResult = ActionResult<(), String>; /// metadata in some way. That is, it has no transformation to make on /// the packet, only add/modify/remove metadata for use by later /// layers. -pub trait MetaAction: Display + Send + Sync { +pub trait MetaAction: Display + ActionProperties + Send + Sync { /// Return the predicates implicit to this action. /// /// Return both the header [`Predicate`] list and @@ -844,7 +858,7 @@ impl From for GenBtError { /// /// For example, you could use this to hairpin an ARP Reply in response /// to a guest's ARP request. -pub trait HairpinAction: Display + Send + Sync { +pub trait HairpinAction: Display + ActionProperties + Send + Sync { /// Generate a [`Packet`] to hairpin back to the source. The /// `meta` argument holds the packet metadata, including any /// modifications made by previous layers up to this point. @@ -933,6 +947,23 @@ impl Action { pub fn is_deny(&self) -> bool { matches!(self, Self::Deny) } + + /// Read-only diagnostic properties exposed by the inner action. + /// + /// Simple variants (`Allow`, `Deny`, `StatefulAllow`, `HandlePacket`) + /// carry no configuration and return an empty list. + pub fn properties(&self) -> Vec { + match self { + Self::Allow + | Self::StatefulAllow + | Self::Deny + | Self::HandlePacket => Vec::new(), + Self::Meta(a) => a.properties(), + Self::Static(a) => a.properties(), + Self::Stateful(a) => a.properties(), + Self::Hairpin(a) => a.properties(), + } + } } #[derive(Clone, Deserialize, Serialize)] @@ -1186,6 +1217,7 @@ impl From<&Rule> for RuleDump { predicates, data_predicates, action: rule.action.to_string(), + action_properties: rule.action.properties(), } } } diff --git a/lib/opte/src/engine/snat.rs b/lib/opte/src/engine/snat.rs index 1765abd7..27fc09ba 100644 --- a/lib/opte/src/engine/snat.rs +++ b/lib/opte/src/engine/snat.rs @@ -17,6 +17,7 @@ use super::packet::Packet; use super::port::meta::ActionMeta; use super::predicate::DataPredicate; use super::predicate::Predicate; +use super::props::ActionProperties; use super::rule::ActionDesc; use super::rule::AllowOrDeny; use super::rule::FiniteHandle; @@ -35,6 +36,7 @@ use crate::ddi::sync::KMutex; use crate::engine::icmp::QueryEcho; use crate::engine::nat::ExternalIpTag; use alloc::collections::btree_map::BTreeMap; +use alloc::string::String; use alloc::string::ToString; use alloc::sync::Arc; use alloc::vec::Vec; @@ -300,6 +302,21 @@ impl Display for SNat { } } +impl ActionProperties for SNat +where + SNat: Display, +{ + fn property_names(&self) -> &'static [&'static str] { + &["priv_ip"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "priv_ip" => Some(self.priv_ip.to_string()), + _ => None, + } + } +} + impl StatefulAction for SNat where SNat: Display, diff --git a/lib/oxide-vpc/src/engine/gateway/mod.rs b/lib/oxide-vpc/src/engine/gateway/mod.rs index e2f410e8..fa7adcb8 100644 --- a/lib/oxide-vpc/src/engine/gateway/mod.rs +++ b/lib/oxide-vpc/src/engine/gateway/mod.rs @@ -195,6 +195,19 @@ impl fmt::Display for RewriteSrcMac { } } +impl opte::engine::props::ActionProperties for RewriteSrcMac { + fn property_names(&self) -> &'static [&'static str] { + &["gateway_mac"] + } + fn get_property(&self, name: &str) -> Option { + use alloc::string::ToString; + match name { + "gateway_mac" => Some(self.gateway_mac.to_string()), + _ => None, + } + } +} + impl StaticAction for RewriteSrcMac { fn gen_ht( &self, @@ -407,6 +420,8 @@ impl VpcMeta { } } +impl opte::engine::props::ActionProperties for VpcMeta {} + impl MetaAction for VpcMeta { fn mod_meta( &self, diff --git a/lib/oxide-vpc/src/engine/overlay.rs b/lib/oxide-vpc/src/engine/overlay.rs index a68d2a40..041e4981 100644 --- a/lib/oxide-vpc/src/engine/overlay.rs +++ b/lib/oxide-vpc/src/engine/overlay.rs @@ -229,6 +229,19 @@ impl fmt::Display for EncapAction { } } +impl opte::engine::props::ActionProperties for EncapAction { + fn property_names(&self) -> &'static [&'static str] { + &["vni", "phys_ip_src"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "vni" => Some(self.vni.to_string()), + "phys_ip_src" => Some(self.phys_ip_src.to_string()), + _ => None, + } + } +} + impl StaticAction for EncapAction { fn gen_ht( &self, @@ -557,6 +570,8 @@ impl fmt::Display for DecapAction { } } +impl opte::engine::props::ActionProperties for DecapAction {} + impl StaticAction for DecapAction { fn gen_ht( &self, @@ -639,6 +654,18 @@ impl MulticastVniValidator { } } +impl opte::engine::props::ActionProperties for MulticastVniValidator { + fn property_names(&self) -> &'static [&'static str] { + &["vni"] + } + fn get_property(&self, name: &str) -> Option { + match name { + "vni" => Some(self.my_vni.to_string()), + _ => None, + } + } +} + impl MetaAction for MulticastVniValidator { fn mod_meta( &self, @@ -1086,3 +1113,36 @@ impl MappingResource for Mcast2Phys { } } } + +#[cfg(test)] +mod tests { + use super::*; + use opte::engine::props::ActionProperties; + + #[test] + fn encap_action_exposes_vni_and_phys_ip() { + let action = EncapAction::new( + "fd00:1122:7788:101::4".parse().unwrap(), + Vni::new(99u32).unwrap(), + Arc::new(Virt2Phys::default()), + Arc::new(Mcast2Phys::default()), + Arc::new(Virt2Boundary::default()), + ); + assert_eq!(action.get_property("vni").as_deref(), Some("99")); + assert_eq!( + action.get_property("phys_ip_src").as_deref(), + Some("fd00:1122:7788:101::4"), + ); + assert!(action.get_property("nonexistent").is_none()); + + let props = action.properties(); + assert_eq!(props.len(), 2); + assert_eq!(props[0].name, "vni"); + assert_eq!(props[1].name, "phys_ip_src"); + } + + #[test] + fn decap_action_has_no_properties() { + assert!(DecapAction::new().properties().is_empty()); + } +} diff --git a/lib/oxide-vpc/src/engine/router.rs b/lib/oxide-vpc/src/engine/router.rs index 3f8b823d..cc5d6159 100644 --- a/lib/oxide-vpc/src/engine/router.rs +++ b/lib/oxide-vpc/src/engine/router.rs @@ -466,6 +466,20 @@ impl fmt::Display for RouterAction { } } +impl opte::engine::props::ActionProperties for RouterAction { + fn property_names(&self) -> &'static [&'static str] { + &["target", "class"] + } + fn get_property(&self, name: &str) -> Option { + use alloc::string::ToString; + match name { + "target" => Some(self.target.to_string()), + "class" => Some(self.target.class().to_string()), + _ => None, + } + } +} + impl MetaAction for RouterAction { fn implicit_preds(&self) -> (Vec, Vec) { (vec![], vec![]) From 0405fda823cbd6fc04aa9f7db58580fb72b944a8 Mon Sep 17 00:00:00 2001 From: MorganaFuture Date: Wed, 27 May 2026 22:05:56 +0300 Subject: [PATCH 2/2] opteadm: add show-prop subcommand MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reads the action properties via the existing dump-layer ioctl and prints them as a small table — or, with -c, as LAYER:DIR:RULE:PROPERTY:VALUE lines (colons and backslashes escaped, dladm show-linkprop -c style). Filters: -l, -d, -r, --prop. Filtering and formatting helpers live in opte::print so they can be unit-tested without linking the illumos-only binary. --- bin/opteadm/src/bin/opteadm.rs | 68 +++++++++ lib/opte/src/print.rs | 269 +++++++++++++++++++++++++++++++++ 2 files changed, 337 insertions(+) diff --git a/bin/opteadm/src/bin/opteadm.rs b/bin/opteadm/src/bin/opteadm.rs index 52b40f7f..59d5f21a 100644 --- a/bin/opteadm/src/bin/opteadm.rs +++ b/bin/opteadm/src/bin/opteadm.rs @@ -18,8 +18,12 @@ use opte::api::MAJOR_VERSION; use opte::api::MacAddr; use opte::api::MulticastUnderlay; use opte::api::Vni; +use opte::api::DumpLayerResp; +use opte::print::collect_prop_rows; use opte::print::print_layer; use opte::print::print_list_layers; +use opte::print::print_props; +use opte::print::print_props_parseable; use opte::print::print_tcp_flows; use opte::print::print_uft; use opte_ioctl::OpteHdl; @@ -100,6 +104,36 @@ enum Command { name: String, }, + /// Show K/V configuration properties exposed by rule actions. + /// + /// Like `dladm show-linkprop`. With no filters, lists every property + /// of every rule on every layer. + ShowProp { + #[arg(short)] + port: String, + + /// Restrict output to a single layer. + #[arg(short = 'l', long)] + layer: Option, + + /// Restrict output to a single direction. + #[arg(short = 'd', long = "dir")] + direction: Option, + + /// Restrict output to a single rule id. + #[arg(short = 'r', long)] + rule_id: Option, + + /// Comma-separated list of property names to show. + #[arg(short = 'p', long, value_delimiter = ',')] + prop: Vec, + + /// Emit `LAYER:DIR:RULE:PROPERTY:VALUE` lines, with `:` and `\` + /// in values backslash-escaped. + #[arg(short = 'c')] + parseable: bool, + }, + /// Clear all entries from the Unified Flow Table. ClearUft { #[arg(short)] @@ -766,6 +800,40 @@ fn main() -> anyhow::Result<()> { print_layer(resp)?; } + Command::ShowProp { + port, + layer, + direction, + rule_id, + prop, + parseable, + } => { + let layers: Vec = match &layer { + Some(name) => vec![hdl.dump_layer(&port, name)?], + None => hdl + .list_layers(&port)? + .layers + .iter() + .map(|l| hdl.dump_layer(&port, &l.name)) + .collect::>()?, + }; + + let prop_filter: Option<&[String]> = + if prop.is_empty() { None } else { Some(&prop) }; + let rows = collect_prop_rows( + &layers, + direction, + rule_id, + prop_filter, + ); + + if parseable { + print_props_parseable(&rows)?; + } else { + print_props(&rows)?; + } + } + Command::ClearUft { port } => { hdl.clear_uft(&port)?; } diff --git a/lib/opte/src/print.rs b/lib/opte/src/print.rs index bb909341..570be864 100644 --- a/lib/opte/src/print.rs +++ b/lib/opte/src/print.rs @@ -16,6 +16,7 @@ use crate::api::InnerFlowId; use crate::api::L4Info; use crate::api::TcpFlowEntryDump; use opte_api::ActionDescEntryDump; +use opte_api::Direction; use opte_api::ListLayersResp; use opte_api::RuleDump; use opte_api::UftEntryDump; @@ -25,6 +26,132 @@ use std::string::String; use std::string::ToString; use tabwriter::TabWriter; +#[derive(Debug, Clone)] +pub struct PropRow<'a> { + pub layer: &'a str, + pub dir: Direction, + pub rule_id: PropRuleId, + pub name: &'a str, + pub value: &'a str, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PropRuleId { + Rule(u64), + Default, +} + +/// Flatten dumped layers into `PropRow`s, filtered by direction, rule +/// id, and/or property name. `None` (or empty slice for `prop_filter`) +/// means no restriction. +pub fn collect_prop_rows<'a>( + layers: &'a [DumpLayerResp], + direction: Option, + rule_id: Option, + prop_filter: Option<&'a [String]>, +) -> Vec> { + let want_in = direction != Some(Direction::Out); + let want_out = direction != Some(Direction::In); + let prop_wanted = |name: &str| -> bool { + prop_filter.is_none_or(|allow| allow.iter().any(|n| n == name)) + }; + + let mut rows = Vec::new(); + for layer in layers { + let dirs = [ + (want_in, Direction::In, &layer.rules_in), + (want_out, Direction::Out, &layer.rules_out), + ]; + for (want, dir, rules) in dirs { + if !want { + continue; + } + for entry in rules { + if rule_id.is_some_and(|r| r != entry.id) { + continue; + } + for prop in &entry.rule.action_properties { + if !prop_wanted(&prop.name) { + continue; + } + rows.push(PropRow { + layer: &layer.name, + dir, + rule_id: PropRuleId::Rule(entry.id), + name: &prop.name, + value: &prop.value, + }); + } + } + } + } + rows +} + +pub fn print_props(rows: &[PropRow<'_>]) -> std::io::Result<()> { + print_props_into(&mut std::io::stdout(), rows) +} + +pub fn print_props_into( + writer: &mut impl Write, + rows: &[PropRow<'_>], +) -> std::io::Result<()> { + let mut t = TabWriter::new(writer); + writeln!(t, "LAYER\tDIR\tRULE\tPROPERTY\tVALUE")?; + for row in rows { + let rule = match row.rule_id { + PropRuleId::Rule(id) => id.to_string(), + PropRuleId::Default => "DEF".to_string(), + }; + writeln!( + t, + "{}\t{}\t{}\t{}\t{}", + row.layer, row.dir, rule, row.name, row.value, + )?; + } + t.flush() +} + +/// Print rows as `LAYER:DIR:RULE:PROPERTY:VALUE`, with `:` and `\` in +/// fields backslash-escaped. Modeled after `dladm show-linkprop -c`. +pub fn print_props_parseable(rows: &[PropRow<'_>]) -> std::io::Result<()> { + print_props_parseable_into(&mut std::io::stdout(), rows) +} + +pub fn print_props_parseable_into( + writer: &mut impl Write, + rows: &[PropRow<'_>], +) -> std::io::Result<()> { + for row in rows { + let rule = match row.rule_id { + PropRuleId::Rule(id) => id.to_string(), + PropRuleId::Default => "-".to_string(), + }; + writeln!( + writer, + "{}:{}:{}:{}:{}", + escape_parseable(row.layer), + escape_parseable(&row.dir.to_string()), + escape_parseable(&rule), + escape_parseable(row.name), + escape_parseable(row.value), + )?; + } + Ok(()) +} + +fn escape_parseable(s: &str) -> String { + let mut out = String::with_capacity(s.len()); + for c in s.chars() { + match c { + '\\' => out.push_str(r"\\"), + ':' => out.push_str(r"\:"), + other => out.push(other), + } + } + out +} + /// Print a [`DumpLayerResp`]. pub fn print_layer(resp: &DumpLayerResp) -> std::io::Result<()> { print_layer_into(&mut std::io::stdout(), resp) @@ -314,3 +441,145 @@ pub fn write_hr(t: &mut impl Write) -> std::io::Result<()> { pub fn print_hr() { println!("{:-<70}", "-"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn escape_parseable_passes_plain_chars() { + assert_eq!(escape_parseable("overlay"), "overlay"); + assert_eq!(escape_parseable(""), ""); + assert_eq!(escape_parseable("99"), "99"); + } + + #[test] + fn escape_parseable_escapes_colon_and_backslash() { + // IPv6 address: every ':' must be escaped. + assert_eq!( + escape_parseable("fd00:1122:7788:101::4"), + r"fd00\:1122\:7788\:101\:\:4", + ); + // Backslash itself doubles up. + assert_eq!(escape_parseable(r"a\b"), r"a\\b"); + // Combined. + assert_eq!(escape_parseable(r"a:b\c"), r"a\:b\\c"); + } + + fn rule_dump( + id: u64, + action: &str, + props: &[(&str, &str)], + ) -> opte_api::RuleTableEntryDump { + opte_api::RuleTableEntryDump { + id, + hits: 0, + rule: RuleDump { + priority: 0, + predicates: vec![], + data_predicates: vec![], + action: action.to_string(), + action_properties: props + .iter() + .map(|(n, v)| opte_api::ActionProperty { + name: n.to_string(), + value: v.to_string(), + }) + .collect(), + }, + } + } + + fn layer(name: &str) -> DumpLayerResp { + DumpLayerResp { + name: name.to_string(), + rules_in: vec![rule_dump(0, "Decap", &[])], + rules_out: vec![ + rule_dump(0, "Encap", &[("vni", "99"), ("phys_ip_src", "fd00::4")]), + rule_dump(1, "Encap", &[("vni", "100")]), + ], + default_in: "deny".into(), + default_in_hits: 0, + default_out: "deny".into(), + default_out_hits: 0, + ft_in: vec![], + ft_out: vec![], + } + } + + #[test] + fn collect_no_filters_returns_all_props() { + let layers = vec![layer("overlay")]; + let rows = collect_prop_rows(&layers, None, None, None); + // 0 inbound props (decap), 3 outbound props (2 + 1). + assert_eq!(rows.len(), 3); + assert!(rows.iter().all(|r| r.layer == "overlay")); + } + + #[test] + fn collect_filters_by_direction() { + let layers = vec![layer("overlay")]; + let rows = collect_prop_rows( + &layers, + Some(Direction::In), + None, + None, + ); + assert!(rows.is_empty(), "decap has no exposed properties"); + } + + #[test] + fn collect_filters_by_rule_id() { + let layers = vec![layer("overlay")]; + let rows = collect_prop_rows(&layers, None, Some(1), None); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].name, "vni"); + assert_eq!(rows[0].value, "100"); + } + + #[test] + fn collect_filters_by_prop_name() { + let layers = vec![layer("overlay")]; + let names: Vec = vec!["vni".into()]; + let rows = collect_prop_rows(&layers, None, None, Some(&names)); + assert_eq!(rows.len(), 2); + assert!(rows.iter().all(|r| r.name == "vni")); + } + + #[test] + fn collect_empty_prop_filter_matches_nothing() { + // Some(&[]) is "an explicit list of zero properties" — the CLI + // should pass None when --prop is omitted, not an empty Vec. + let layers = vec![layer("overlay")]; + let empty: Vec = vec![]; + let rows = collect_prop_rows(&layers, None, None, Some(&empty)); + assert!(rows.is_empty()); + } + + #[test] + fn print_props_parseable_emits_one_line_per_row() { + let rows = vec![ + PropRow { + layer: "overlay", + dir: Direction::Out, + rule_id: PropRuleId::Rule(0), + name: "vni", + value: "99", + }, + PropRow { + layer: "overlay", + dir: Direction::Out, + rule_id: PropRuleId::Default, + name: "phys_ip_src", + value: "fd00::4", + }, + ]; + let mut buf = Vec::new(); + print_props_parseable_into(&mut buf, &rows).unwrap(); + let out = String::from_utf8(buf).unwrap(); + assert_eq!( + out, + "overlay:OUT:0:vni:99\noverlay:OUT:-:phys_ip_src:fd00\\:\\:4\n", + ); + } +}