From 26bfdd45fffeacbab8442f558462607d957a4ef6 Mon Sep 17 00:00:00 2001 From: 007gzs <007gzs@gmail.com> Date: Fri, 14 Mar 2025 20:43:19 +0800 Subject: [PATCH] Rust WASM plugin support for matching service and route name prefixes is effective. (#1882) --- plugins/wasm-rust/Cargo.toml | 2 +- plugins/wasm-rust/src/rule_matcher.rs | 488 ++++++++++++++++++++++++-- 2 files changed, 464 insertions(+), 26 deletions(-) diff --git a/plugins/wasm-rust/Cargo.toml b/plugins/wasm-rust/Cargo.toml index f57d11bbf..3f32d70fd 100644 --- a/plugins/wasm-rust/Cargo.toml +++ b/plugins/wasm-rust/Cargo.toml @@ -7,7 +7,7 @@ edition = "2021" [dependencies] proxy-wasm = { git="https://github.com/higress-group/proxy-wasm-rust-sdk", branch="main", version="0.2.2" } -serde = "1.0" +serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" multimap = "0" http = "1" diff --git a/plugins/wasm-rust/src/rule_matcher.rs b/plugins/wasm-rust/src/rule_matcher.rs index dc42b433f..a342d4e7e 100644 --- a/plugins/wasm-rust/src/rule_matcher.rs +++ b/plugins/wasm-rust/src/rule_matcher.rs @@ -15,8 +15,10 @@ use crate::error::WasmRustError; use crate::internal::{get_http_request_header, get_property}; use crate::log::Log; +#[cfg(not(test))] use proxy_wasm::hostcalls::log; use proxy_wasm::traits::RootContext; +#[cfg(not(test))] use proxy_wasm::types::LogLevel; use serde::de::DeserializeOwned; use serde_json::{from_slice, Map, Value}; @@ -25,11 +27,15 @@ use std::cell::RefCell; use std::collections::HashSet; use std::rc::Rc; +#[derive(PartialEq)] enum Category { Route, Host, + RoutePrefix, + Service, } +#[derive(PartialEq)] enum MatchType { Prefix, Exact, @@ -39,9 +45,12 @@ enum MatchType { const RULES_KEY: &str = "_rules_"; const MATCH_ROUTE_KEY: &str = "_match_route_"; const MATCH_DOMAIN_KEY: &str = "_match_domain_"; +const MATCH_SERVICE_KEY: &str = "_match_service_"; +const MATCH_ROUTE_PREFIX_KEY: &str = "_match_route_prefix_"; pub type SharedRuleMatcher = Rc>>; +#[derive(PartialEq)] struct HostMatcher { match_type: MatchType, host: String, @@ -51,6 +60,8 @@ struct RuleConfig { category: Category, routes: HashSet, hosts: Vec, + route_prefixes: HashSet, + services: HashSet, config: Rc, } @@ -64,6 +75,16 @@ impl RuleMatcher where PluginConfig: Default + DeserializeOwned, { + pub fn override_config( + &mut self, + override_func: fn(config: &PluginConfig, global: &PluginConfig) -> PluginConfig, + ) { + if let Some(global) = &self.global_config { + for rule_config in &mut self.rule_config { + rule_config.config = Rc::new(override_func(rule_config.config.borrow(), global)); + } + } + } pub fn parse_rule_config(&mut self, config: &Value) -> Result<(), WasmRustError> { let empty_object = Map::new(); let empty_vec = Vec::new(); @@ -90,11 +111,15 @@ where self.global_config = Some(Rc::new(plugin_config)); } Err(err) => { - log( - LogLevel::Warn, - format!("parse global config failed, err:{:?}", err).as_str(), - ) - .unwrap(); + #[cfg(not(test))] + { + log( + LogLevel::Warn, + format!("parse global config failed, err:{:?}", err).as_str(), + ) + .unwrap(); + } + global_config_error = WasmRustError::new(err.to_string()); } } @@ -117,24 +142,39 @@ where }; let routes = RuleMatcher::::parse_route_match_config(rule_json); let hosts = RuleMatcher::::parse_host_match_config(rule_json); + let services = RuleMatcher::::parse_service_match_config(rule_json); + let route_prefixes = + RuleMatcher::::parse_route_prefix_match_config(rule_json); let no_routes = routes.is_empty(); let no_hosts = hosts.is_empty(); - - if (no_routes && no_hosts) || (!no_routes && !no_hosts) { - return Err(WasmRustError::new("there is only one of '_match_route_' and '_match_domain_' can present in configuration.".to_string())); + let no_service = services.is_empty(); + let no_route_prefix = route_prefixes.is_empty(); + if [no_routes, no_hosts, no_service, no_route_prefix] + .iter() + .filter(|&x| *x) + .count() + != 3 + { + return Err(WasmRustError::new("there is only one of '_match_route_', '_match_domain_', '_match_service_' and '_match_route_prefix_' can present in configuration.".to_string())); } - let category = if no_routes { - Category::Host - } else { + let category = if !no_routes { Category::Route + } else if !no_hosts { + Category::Host + } else if !no_service { + Category::Service + } else { + Category::RoutePrefix }; self.rule_config.push(RuleConfig { category, routes, hosts, + route_prefixes, + services, config: Rc::new(config), }) } @@ -144,7 +184,11 @@ where pub fn get_match_config(&self) -> Option<(i64, Rc)> { let host = get_http_request_header(":authority").unwrap_or_default(); - let route_name = get_property(vec!["route_name"]).unwrap_or_default(); + let route_name = String::from_utf8(get_property(vec!["route_name"]).unwrap_or_default()) + .unwrap_or_else(|_| "".to_string()); + let service_name = + String::from_utf8(get_property(vec!["cluster_name"]).unwrap_or_default()) + .unwrap_or_else(|_| "".to_string()); for (i, rule) in self.rule_config.iter().enumerate() { match rule.category { @@ -154,11 +198,19 @@ where } } Category::Route => { - if rule.routes.contains( - String::from_utf8(route_name.to_vec()) - .unwrap_or_else(|_| "".to_string()) - .as_str(), - ) { + if rule.routes.contains(route_name.as_str()) { + return Some((i as i64, rule.config.clone())); + } + } + Category::RoutePrefix => { + for route_prefix in &rule.route_prefixes { + if route_name.starts_with(route_prefix) { + return Some((i as i64, rule.config.clone())); + } + } + } + Category::Service => { + if self.service_match(rule, &service_name) { return Some((i as i64, rule.config.clone())); } } @@ -180,17 +232,26 @@ where } } - fn parse_route_match_config(config: &Value) -> HashSet { + fn parse_match_config(json_key: &str, config: &Value) -> HashSet { let empty_vec = Vec::new(); - let keys = config[MATCH_ROUTE_KEY].as_array().unwrap_or(&empty_vec); - let mut routes = HashSet::new(); + let keys = config[json_key].as_array().unwrap_or(&empty_vec); + let mut values = HashSet::new(); for key in keys { - let route_name = key.as_str().unwrap_or("").to_string(); - if !route_name.is_empty() { - routes.insert(route_name); + let value = key.as_str().unwrap_or("").to_string(); + if !value.is_empty() { + values.insert(value); } } - routes + values + } + fn parse_route_match_config(config: &Value) -> HashSet { + Self::parse_match_config(MATCH_ROUTE_KEY, config) + } + fn parse_service_match_config(config: &Value) -> HashSet { + Self::parse_match_config(MATCH_SERVICE_KEY, config) + } + fn parse_route_prefix_match_config(config: &Value) -> HashSet { + Self::parse_match_config(MATCH_ROUTE_PREFIX_KEY, config) } fn parse_host_match_config(config: &Value) -> Vec { @@ -217,8 +278,21 @@ where } host_matchers } - + fn strip_port_from_host(req_host: &str) -> String { + // Port removing code is inspired by + // https://github.com/envoyproxy/envoy/blob/v1.17.0/source/common/http/header_utility.cc#L219 + if let Some(port_start) = req_host.rfind(':') { + // According to RFC3986 v6 address is always enclosed in "[]". + // section 3.2.2. + let v6_end_index = req_host.rfind(']'); + if v6_end_index.map_or(true, |idx| idx < port_start) && port_start < req_host.len() { + return req_host[..port_start].to_string(); + } + } + req_host.to_string() + } fn host_match(&self, rule: &RuleConfig, request_host: &str) -> bool { + let request_host = Self::strip_port_from_host(request_host); for host in &rule.hosts { let matched = match host.match_type { MatchType::Prefix => request_host.starts_with(host.host.as_str()), @@ -231,6 +305,26 @@ where } false } + fn service_match(&self, rule: &RuleConfig, service_name: &str) -> bool { + let parts = service_name.split("|").collect::>(); + if parts.len() != 4 { + return false; + } + let port = parts[1]; + let fqdn = parts[3]; + for config_service_name in &rule.services { + if let Some(colon_index) = config_service_name.rfind(':') { + if fqdn == &config_service_name[..colon_index] + && port == &config_service_name[colon_index + 1..] + { + return true; + } + } else if fqdn == config_service_name { + return true; + } + } + false + } } pub fn on_configure( @@ -257,3 +351,347 @@ pub fn on_configure( rule_matcher.parse_rule_config(&value).is_ok() } + +#[cfg(test)] +mod tests { + use std::vec; + + use serde::Deserialize; + + use super::*; + + #[derive(Default, Deserialize, PartialEq, Eq)] + struct CustomConfig { + #[serde(default)] + name: String, + #[serde(default)] + age: i64, + } + + impl CustomConfig { + fn new(name: &str, age: i64) -> Self { + CustomConfig { + name: name.to_string(), + age, + } + } + } + struct RuleConfigBuilder { + config: RuleConfig, + } + + impl RuleConfigBuilder { + fn new(category: Category, config: Rc) -> Self { + RuleConfigBuilder { + config: RuleConfig { + category, + config, + routes: HashSet::default(), + hosts: Vec::default(), + route_prefixes: HashSet::default(), + services: HashSet::default(), + }, + } + } + fn add_host(mut self, match_type: MatchType, host: &str) -> Self { + self.config.hosts.push(HostMatcher { + match_type, + host: host.to_string(), + }); + self + } + fn add_route(mut self, route: &str) -> Self { + self.config.routes.insert(route.to_string()); + self + } + fn add_route_prefix(mut self, route_prefix: &str) -> Self { + self.config.route_prefixes.insert(route_prefix.to_string()); + self + } + fn add_service(mut self, service_name: &str) -> Self { + self.config.services.insert(service_name.to_string()); + self + } + fn config(self) -> RuleConfig { + self.config + } + } + struct MatchTestCase { + name: String, + config: RuleConfig, + key: String, + result: bool, + } + impl MatchTestCase { + fn new(name: &str, key: &str, result: bool, config: RuleConfig) -> Self { + MatchTestCase { + name: name.to_string(), + key: key.to_string(), + result, + config, + } + } + } + #[test] + fn test_host_match() { + let config = Rc::new(CustomConfig::new("test", 1)); + let cases = vec![ + MatchTestCase::new( + "prefix", + "www.test.com", + true, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Prefix, "www.") + .config(), + ), + MatchTestCase::new( + "prefix failed", + "test.com", + false, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Prefix, "www.") + .config(), + ), + MatchTestCase::new( + "suffix", + "www.example.com", + true, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Suffix, ".example.com") + .config(), + ), + MatchTestCase::new( + "suffix failed", + "example.com", + false, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Suffix, ".example.com") + .config(), + ), + MatchTestCase::new( + "exact", + "www.example.com", + true, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Exact, "www.example.com") + .config(), + ), + MatchTestCase::new( + "exact failed", + "example.com", + false, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Exact, "www.example.com") + .config(), + ), + MatchTestCase::new( + "exact port", + "www.example.com:8080", + true, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Exact, "www.example.com") + .config(), + ), + MatchTestCase::new( + "any", + "www.example.com", + true, + RuleConfigBuilder::new(Category::Host, config.clone()) + .add_host(MatchType::Suffix, "") + .config(), + ), + ]; + for case in &cases { + println!("test {} start", case.name); + let rule = RuleMatcher::default(); + assert_eq!( + case.result, + rule.host_match(&case.config, case.key.as_str()) + ); + } + } + + #[test] + fn test_service_match() { + let config = Rc::new(CustomConfig::new("test", 1)); + let cases = vec![ + MatchTestCase::new( + "fqdn", + "outbound|443||qwen.dns", + true, + RuleConfigBuilder::new(Category::Service, config.clone()) + .add_service("qwen.dns") + .config(), + ), + MatchTestCase::new( + "fqdn with port", + "outbound|443||qwen.dns", + true, + RuleConfigBuilder::new(Category::Service, config.clone()) + .add_service("qwen.dns:443") + .config(), + ), + MatchTestCase::new( + "not match", + "outbound|443||qwen.dns", + false, + RuleConfigBuilder::new(Category::Service, config.clone()) + .add_service("moonshot.dns:443") + .config(), + ), + MatchTestCase::new( + "error config format", + "outbound|443||qwen.dns", + false, + RuleConfigBuilder::new(Category::Service, config.clone()) + .add_service("qwen.dns:") + .config(), + ), + ]; + for case in &cases { + println!("test {} start", case.name); + let rule = RuleMatcher::default(); + assert_eq!( + case.result, + rule.service_match(&case.config, case.key.as_str()) + ); + } + } + + struct ParseTestCase { + name: String, + config: String, + err_msg: String, + expected: RuleMatcher, + } + + impl ParseTestCase + where + Config: DeserializeOwned + PartialEq + Eq + Default, + { + fn new(name: &str, config: &str, err_msg: &str) -> Self { + ParseTestCase { + name: name.to_string(), + config: config.to_string(), + err_msg: err_msg.to_string(), + expected: RuleMatcher::default(), + } + } + fn global_config(mut self, config: Config) -> Self { + self.expected.global_config = Some(Rc::new(config)); + self + } + + fn rule_config(mut self, config: RuleConfig) -> Self { + self.expected.rule_config.push(config); + self + } + fn is_eq(&self, other: &RuleMatcher) -> bool { + if self.expected.global_config.is_some() != other.global_config.is_some() { + return false; + } + if let (Some(a), Some(b)) = (&self.expected.global_config, &other.global_config) { + if a != b { + return false; + } + } + if self.expected.rule_config.len() != other.rule_config.len() { + return false; + } + for (s, o) in self + .expected + .rule_config + .iter() + .zip(other.rule_config.iter()) + { + if s.category != o.category + || s.config != o.config + || s.routes != o.routes + || s.hosts != o.hosts + || s.route_prefixes != o.route_prefixes + || s.services != o.services + { + return false; + } + } + true + } + } + + #[test] + fn test_parse_rule_config() { + let cases = vec![ + ParseTestCase::new("global config", r#"{"name":"john", "age":18}"#, "").global_config(CustomConfig::new("john", 18)), + ParseTestCase::new("no rule", r#"{"_rules_":[]}"#, "parse config failed, no valid rules; global config parse error:"), + ParseTestCase::new("invalid rule", r#"{"_rules_":[{"_match_domain_":["*"],"_match_route_":["test"]}]}"#, "there is only one of '_match_route_', '_match_domain_', '_match_service_' and '_match_route_prefix_' can present in configuration."), + ParseTestCase::new("invalid rule", r#"{"_rules_":[{"_match_domain_":["*"],"_match_service_":["test.dns"]}]}"#, "there is only one of '_match_route_', '_match_domain_', '_match_service_' and '_match_route_prefix_' can present in configuration."), + ParseTestCase::new("invalid rule", r#"{"_rules_":[{"age":16}]}"#, "there is only one of '_match_route_', '_match_domain_', '_match_service_' and '_match_route_prefix_' can present in configuration."), + ParseTestCase::new("rules config", r#"{"_rules_":[{"_match_domain_":["*.example.com","www.*","*","www.abc.com"],"name":"john", "age":18},{"_match_route_":["test1","test2"],"name":"ann", "age":16},{"_match_service_":["test1.dns","test2.static:8080"],"name":"ann", "age":16},{"_match_route_prefix_":["api1","api2"],"name":"ann", "age":16}]}"#, "") + .rule_config(RuleConfigBuilder::new(Category::Host, Rc::new(CustomConfig::new("john", 18))).add_host(MatchType::Suffix, ".example.com").add_host(MatchType::Prefix, "www.").add_host(MatchType::Suffix, "").add_host(MatchType::Exact, "www.abc.com").config()) + .rule_config(RuleConfigBuilder::new(Category::Route, Rc::new(CustomConfig::new("ann", 16))).add_route("test1").add_route("test2").config()) + .rule_config(RuleConfigBuilder::new(Category::Service, Rc::new(CustomConfig::new("ann", 16))).add_service("test1.dns").add_service("test2.static:8080").config()) + .rule_config(RuleConfigBuilder::new(Category::RoutePrefix, Rc::new(CustomConfig::new("ann", 16))).add_route_prefix("api1").add_route_prefix("api2").config()) + ]; + for case in &cases { + println!("test {} start", case.name); + + let mut rule = RuleMatcher::default(); + + let res = rule.parse_rule_config(&serde_json::from_str(&case.config).unwrap()); + if let Err(e) = res { + assert_eq!(case.err_msg, e.to_string()); + } else { + assert!(case.err_msg.is_empty()) + } + assert!(case.is_eq(&rule)); + } + } + + #[derive(Default, Clone, Deserialize, PartialEq, Eq)] + struct CompleteConfig { + // global config + #[serde(default)] + consumers: Vec, + // rule config + #[serde(default)] + allow: Vec, + } + impl CompleteConfig { + fn new(consumers: Vec<&str>, allow: Vec<&str>) -> Self { + CompleteConfig { + consumers: consumers.iter().map(|s| s.to_string()).collect(), + allow: allow.iter().map(|s| s.to_string()).collect(), + } + } + } + fn override_config(config: &CompleteConfig, global: &CompleteConfig) -> CompleteConfig { + let mut new_config = global.clone(); + new_config.allow.extend(config.allow.clone()); + new_config + } + + #[test] + fn test_parse_override_config() { + let cases = vec![ + ParseTestCase::new("override rule config", r#"{"consumers":["c1","c2","c3"],"_rules_":[{"_match_route_":["r1","r2"],"allow":["c1","c3"]}]}"#, "") + .global_config(CompleteConfig::new(vec!["c1", "c2", "c3"], vec![])) + .rule_config(RuleConfigBuilder::new(Category::Route, Rc::new(CompleteConfig::new(vec!["c1", "c2", "c3"], vec!["c1", "c3"]))).add_route("r1").add_route("r2").config()) + ]; + for case in &cases { + println!("test {} start", case.name); + + let mut rule = RuleMatcher::default(); + + let res = rule.parse_rule_config(&serde_json::from_str(&case.config).unwrap()); + if res.is_ok() { + rule.override_config(override_config); + } + if let Err(e) = res { + assert_eq!(case.err_msg, e.to_string()); + } else { + assert!(case.err_msg.is_empty()) + } + assert!(case.is_eq(&rule)); + } + } +}