featherbit/plugins/native/
proxy_mirror.rs1use async_trait::async_trait;
15use std::collections::HashMap;
16use std::sync::Arc;
17use std::time::Duration;
18
19use crate::context::Context;
20use crate::outbound::{OutboundClient, OutboundRequest};
21use crate::plugins::resources::PluginResources;
22use crate::plugins::{Plugin, PluginOutput, PluginResult};
23use crate::vars::template::Template;
24
25const MIRROR_TIMEOUT: Duration = Duration::from_secs(60);
27
28pub struct ProxyMirrorPlugin {
30 host: Template,
35 path: Option<Template>,
38 sample_ratio: f64,
40 client: Arc<OutboundClient>,
42}
43
44fn roll_fraction() -> f64 {
51 use std::collections::hash_map::RandomState;
52 use std::hash::{BuildHasher, Hasher};
53 let n = RandomState::new().build_hasher().finish();
54 n as f64 / (u64::MAX as f64 + 1.0)
56}
57
58impl ProxyMirrorPlugin {
59 pub fn from_config(
80 config: &HashMap<String, serde_json::Value>,
81 resources: &Arc<PluginResources>,
82 ) -> Result<Self, String> {
83 let host = config
84 .get("host")
85 .and_then(|v| v.as_str())
86 .filter(|s| !s.is_empty())
87 .ok_or("proxy-mirror requires 'host' (e.g. \"http://shadow:8080\")")?
88 .trim_end_matches('/')
89 .to_string();
90
91 if !host.starts_with("http://") && !host.starts_with("https://") {
92 return Err(format!(
93 "proxy-mirror 'host' must start with http:// or https:// (got '{host}')"
94 ));
95 }
96 let host = Template::parse(&host).0;
99
100 let path = config
101 .get("path")
102 .and_then(|v| v.as_str())
103 .filter(|s| !s.is_empty())
104 .map(|s| Template::parse(s).0);
105
106 let sample_ratio = match config.get("sample_ratio") {
107 None => 1.0,
108 Some(v) => v
109 .as_f64()
110 .filter(|r| (0.0..=1.0).contains(r))
111 .ok_or("proxy-mirror 'sample_ratio' must be a number in 0.0..=1.0")?,
112 };
113
114 Ok(Self {
115 host,
116 path,
117 sample_ratio,
118 client: resources.outbound.clone(),
119 })
120 }
121
122 fn should_mirror(&self) -> bool {
127 if self.sample_ratio >= 1.0 {
128 true
129 } else {
130 roll_fraction() < self.sample_ratio
131 }
132 }
133
134 fn build_request(&self, ctx: &Context) -> OutboundRequest {
139 let host = self.host.render(ctx);
140 let path = match &self.path {
141 Some(tpl) => tpl.render(ctx),
142 None => std::borrow::Cow::Borrowed(ctx.request.path.as_str()),
143 };
144 let url = match crate::vars::resolve(ctx, "query_string") {
145 Some(qs) => format!("{}{}?{}", host, path, qs),
146 None => format!("{}{}", host, path),
147 };
148
149 let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::GET);
150
151 let mut headers: Vec<(String, String)> = Vec::new();
152 for (name, values) in &ctx.request.headers {
153 for value in values {
154 headers.push((name.clone(), value.clone()));
155 }
156 }
157
158 OutboundRequest {
159 method,
160 url,
161 headers,
162 body: ctx.request.body.clone(),
163 timeout: MIRROR_TIMEOUT,
164 ssl_verify: true,
165 tls: None,
166 }
167 }
168}
169
170#[async_trait]
171impl Plugin for ProxyMirrorPlugin {
172 fn plugin_type(&self) -> &str {
173 "proxy-mirror"
174 }
175
176 async fn execute(&self, ctx: Context) -> PluginResult {
177 if self.should_mirror() {
178 let request = self.build_request(&ctx);
182 let client = self.client.clone();
183 tokio::spawn(async move {
184 let _ = client.request(request).await;
185 });
186 }
187
188 Ok(PluginOutput::success(ctx))
189 }
190}
191
192#[cfg(test)]
193mod tests {
194 use super::*;
195 use crate::context::{GatewayRequest, GatewayResponse, Protocol};
196 use bytes::Bytes;
197
198 fn test_ctx() -> Context {
199 let mut headers = HashMap::new();
200 headers.insert("x-trace".to_string(), vec!["t1".to_string()]);
201 let mut query = HashMap::new();
202 query.insert("q".to_string(), vec!["1".to_string()]);
203 Context {
204 request: GatewayRequest {
205 method: "POST".to_string(),
206 path: "/api/users".to_string(),
207 host: "example.com".to_string(),
208 scheme: "http".to_string(),
209 headers,
210 query_params: query,
211 body: Bytes::from_static(b"payload"),
212 remote_addr: "10.1.2.3:44321".to_string(),
213 protocol: Protocol::Http1,
214 },
215 response: GatewayResponse {
216 status_code: 0,
217 headers: HashMap::new(),
218 body: Bytes::new(),
219 stream: None,
220 },
221 message: HashMap::new(),
222 errors: Vec::new(),
223 }
224 }
225
226 fn plugin(config: serde_json::Value) -> Result<ProxyMirrorPlugin, String> {
227 let map: HashMap<String, serde_json::Value> = serde_json::from_value(config).unwrap();
228 ProxyMirrorPlugin::from_config(&map, &PluginResources::empty())
229 }
230
231 #[test]
232 fn test_config_requires_valid_host() {
233 assert!(plugin(serde_json::json!({})).is_err());
234 assert!(plugin(serde_json::json!({ "host": "" })).is_err());
235 assert!(plugin(serde_json::json!({ "host": "shadow:8080" })).is_err());
236 assert!(plugin(serde_json::json!({ "host": "http://shadow:8080" })).is_ok());
237 assert!(plugin(serde_json::json!({ "host": "http://s", "sample_ratio": 2 })).is_err());
239 }
240
241 #[test]
242 fn test_sampling_decision() {
243 let never = plugin(serde_json::json!({ "host": "http://s", "sample_ratio": 0 })).unwrap();
244 let always = plugin(serde_json::json!({ "host": "http://s", "sample_ratio": 1 })).unwrap();
245 for _ in 0..100 {
246 assert!(!never.should_mirror(), "ratio 0 must never mirror");
247 assert!(always.should_mirror(), "ratio 1 must always mirror");
248 }
249 }
250
251 #[test]
252 fn test_build_request_default_path() {
253 let p = plugin(serde_json::json!({ "host": "http://shadow:8080" })).unwrap();
254 let req = p.build_request(&test_ctx());
255 assert_eq!(req.url, "http://shadow:8080/api/users?q=1");
256 assert_eq!(req.method, http::Method::POST);
257 assert_eq!(req.body, Bytes::from_static(b"payload"));
258 assert!(req.headers.iter().any(|(k, v)| k == "x-trace" && v == "t1"));
259 }
260
261 #[test]
262 fn test_build_request_host_and_path_render_template() {
263 let p = plugin(serde_json::json!({
264 "host": "http://{{request.headers.x-shadow-host}}",
265 "path": "/mirror/{{request.headers.x-tenant}}"
266 }))
267 .unwrap();
268 let mut ctx = test_ctx();
269 ctx.request
270 .headers
271 .insert("x-shadow-host".to_string(), vec!["shadow:9090".to_string()]);
272 ctx.request
273 .headers
274 .insert("x-tenant".to_string(), vec!["acme".to_string()]);
275 let req = p.build_request(&ctx);
276 assert_eq!(req.url, "http://shadow:9090/mirror/acme?q=1");
277 }
278
279 #[test]
280 fn test_build_request_path_override() {
281 let p = plugin(serde_json::json!({
282 "host": "http://shadow:8080/", "path": "/mirror"
283 }))
284 .unwrap();
285 let req = p.build_request(&test_ctx());
286 assert_eq!(req.url, "http://shadow:8080/mirror?q=1");
288 }
289
290 #[tokio::test]
291 async fn test_execute_returns_ok_and_leaves_context() {
292 let p = plugin(serde_json::json!({
295 "host": "http://127.0.0.1:1", "sample_ratio": 1
296 }))
297 .unwrap();
298 let out = p.execute(test_ctx()).await.unwrap();
299 assert_eq!(out.context.request.path, "/api/users");
300 assert_eq!(out.context.response.status_code, 0);
301 }
302}