1use async_trait::async_trait;
6use std::collections::HashMap;
7use std::sync::Arc;
8use std::time::Duration;
9
10use crate::balancer::{Balancer, Strategy, Target};
11use crate::context::{Context, GatewayError, Protocol};
12use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
13use crate::plugins::resources::PluginResources;
14use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
15
16pub struct UpstreamPlugin {
23 balancer: Balancer,
25 client: Arc<OutboundClient>,
27 timeout: Duration,
29 tls: bool,
31 ssl_verify: bool,
34 tls_identity: Option<Arc<crate::outbound::tls::UpstreamTls>>,
37}
38
39impl std::fmt::Debug for UpstreamPlugin {
43 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
44 f.debug_struct("UpstreamPlugin")
45 .field("timeout", &self.timeout)
46 .field("tls", &self.tls)
47 .field("ssl_verify", &self.ssl_verify)
48 .field("tls_identity_set", &self.tls_identity.is_some())
49 .finish()
50 }
51}
52
53impl UpstreamPlugin {
54 pub fn from_config(
103 config: &HashMap<String, serde_json::Value>,
104 resources: &Arc<PluginResources>,
105 ) -> Result<Self, String> {
106 let targets = config
109 .get("targets")
110 .and_then(|v| v.as_array())
111 .map(|seq| {
112 seq.iter()
113 .filter_map(|t| {
114 let mapping = t.as_object()?;
115 let host = mapping.get("host")?.as_str()?.to_string();
116 let port = mapping.get("port")?.as_u64()? as u16;
117 Some(Target { host, port })
118 })
119 .collect::<Vec<_>>()
120 })
121 .unwrap_or_default();
122
123 let strategy = match config
126 .get("load_balancing")
127 .or_else(|| config.get("load_balancer"))
128 {
129 None => Strategy::default(),
130 Some(v) => {
131 let s = v
132 .as_str()
133 .ok_or_else(|| "load_balancing must be a string".to_string())?;
134 Strategy::parse(s)?
135 }
136 };
137
138 let balancer = Balancer::new(targets, strategy)?;
139
140 let timeout = Duration::from_millis(
141 config
142 .get("timeout_ms")
143 .and_then(|v| v.as_u64())
144 .unwrap_or(60_000),
145 );
146
147 let tls = config.get("tls").and_then(|v| v.as_bool()).unwrap_or(false);
148 let ssl_verify = config
149 .get("ssl_verify")
150 .and_then(|v| v.as_bool())
151 .unwrap_or(true);
152
153 let string_config_value = |key: &str| -> Result<Option<String>, String> {
154 match config.get(key) {
155 None => Ok(None),
156 Some(v) => match v.as_str() {
157 Some(s) => Ok(Some(s.to_string())),
158 None => Err(format!("{} must be a string", key)),
159 },
160 }
161 };
162
163 let client_cert_path = string_config_value("client_cert_path")?;
164 let client_key_path = string_config_value("client_key_path")?;
165 let ca_cert_path = string_config_value("ca_cert_path")?;
166
167 let any_mtls_key =
168 client_cert_path.is_some() || client_key_path.is_some() || ca_cert_path.is_some();
169 if any_mtls_key && !tls {
170 return Err(
171 "client_cert_path/client_key_path/ca_cert_path require tls: true".to_string(),
172 );
173 }
174 if client_cert_path.is_some() != client_key_path.is_some() {
175 return Err("client_cert_path and client_key_path must be set together".to_string());
176 }
177 if ca_cert_path.is_some() && !ssl_verify {
180 return Err("ca_cert_path with ssl_verify: false is contradictory".to_string());
181 }
182
183 let tls_identity = if any_mtls_key {
184 let client = client_cert_path.as_deref().zip(client_key_path.as_deref());
185 let identity = crate::outbound::tls::UpstreamTls::load(
186 client,
187 ca_cert_path.as_deref(),
188 ssl_verify,
189 )?;
190 crate::outbound::tls::UpstreamTls::register(&identity);
191 Some(identity)
192 } else {
193 None
194 };
195
196 Ok(Self {
197 balancer,
198 client: resources.outbound.clone(),
199 timeout,
200 tls,
201 ssl_verify,
202 tls_identity,
203 })
204 }
205}
206
207#[async_trait]
208impl Plugin for UpstreamPlugin {
209 fn plugin_type(&self) -> &str {
210 "upstream"
211 }
212
213 async fn execute(
214 &self,
215 mut ctx: Context,
216 _named_inputs: &HashMap<String, serde_json::Value>,
217 ) -> PluginResult {
218 let target_idx = self.balancer.select(&ctx.request.remote_addr);
219 let target = self.balancer.target(target_idx);
220
221 if ctx.request.protocol == Protocol::WebSocket {
228 ctx.message.insert(
229 "__ws_upstream_host".to_string(),
230 serde_json::json!(target.host),
231 );
232 ctx.message.insert(
233 "__ws_upstream_port".to_string(),
234 serde_json::json!(target.port),
235 );
236 ctx.message.insert(
237 "__ws_upstream_path".to_string(),
238 serde_json::json!(ctx.request.path),
239 );
240 ctx.message
241 .insert("__ws_upstream_tls".to_string(), serde_json::json!(self.tls));
242 ctx.message.insert(
243 "__ws_upstream_verify".to_string(),
244 serde_json::json!(self.ssl_verify),
245 );
246 if let Some(identity) = &self.tls_identity {
247 ctx.message.insert(
248 "__ws_upstream_tls_key".to_string(),
249 serde_json::json!(identity.cache_key()),
250 );
251 }
252 ctx.response.status_code = 101;
253 return Ok(PluginOutput {
254 context: ctx,
255 named_outputs: HashMap::new(),
256 });
257 }
258
259 let _in_flight_guard = self.balancer.acquire(target_idx);
260 let scheme = if self.tls { "https" } else { "http" };
261 let uri = format!(
262 "{}://{}:{}{}",
263 scheme, target.host, target.port, ctx.request.path
264 );
265
266 let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::GET);
267
268 let mut headers: Vec<(String, String)> = Vec::new();
270 for (key, values) in &ctx.request.headers {
271 if key.eq_ignore_ascii_case("host") {
272 continue;
273 }
274 for value in values {
275 headers.push((key.clone(), value.clone()));
276 }
277 }
278 headers.push((
279 "host".to_string(),
280 format!("{}:{}", target.host, target.port),
281 ));
282
283 let outbound = OutboundRequest {
284 method,
285 url: uri,
286 headers,
287 body: ctx.request.body.clone(),
288 timeout: self.timeout,
289 ssl_verify: self.ssl_verify,
290 tls: self.tls_identity.clone(),
291 };
292
293 let response = match self.client.request(outbound).await {
294 Ok(resp) => resp,
295 Err(e) => {
296 let (code, message) = match &e {
297 OutboundError::Timeout(d) => (
298 "UPSTREAM_TIMEOUT",
299 format!(
300 "Upstream {}:{} timed out after {:?}",
301 target.host, target.port, d
302 ),
303 ),
304 OutboundError::InvalidRequest(m) => (
305 "UPSTREAM_REQUEST_BUILD_ERROR",
306 format!("Failed to build upstream request: {}", m),
307 ),
308 OutboundError::Transport(m) => (
309 "UPSTREAM_CONNECTION_ERROR",
310 format!(
311 "Failed to reach upstream {}:{}: {}",
312 target.host, target.port, m
313 ),
314 ),
315 };
316 let error = GatewayError {
317 node_id: String::new(),
318 code: code.to_string(),
319 message,
320 metadata: HashMap::new(),
321 };
322 return Err(PluginExecutionError {
323 context: ctx,
324 error,
325 });
326 }
327 };
328
329 ctx.response.status_code = response.status;
331 ctx.response.headers = response.headers;
332 ctx.response.body = response.body;
333
334 Ok(PluginOutput {
335 context: ctx,
336 named_outputs: HashMap::new(),
337 })
338 }
339}
340
341#[cfg(test)]
342mod tests {
343 use super::*;
344
345 fn plugin_with(strategy: Option<&str>, key: &str, n_targets: usize) -> UpstreamPlugin {
346 let targets: Vec<serde_json::Value> = (0..n_targets)
347 .map(|i| serde_json::json!({ "host": format!("backend-{}", i), "port": 3000 }))
348 .collect();
349 let mut config = HashMap::new();
350 config.insert("targets".to_string(), serde_json::Value::Array(targets));
351 if let Some(s) = strategy {
352 config.insert(key.to_string(), serde_json::Value::String(s.to_string()));
353 }
354 UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap()
355 }
356
357 #[test]
358 fn test_load_balancing_parsing_and_aliases() {
359 assert_eq!(
361 plugin_with(Some("least_connections"), "load_balancing", 2)
362 .balancer
363 .strategy(),
364 Strategy::LeastConnections
365 );
366 assert_eq!(
368 plugin_with(Some("round-robin"), "load_balancer", 2)
369 .balancer
370 .strategy(),
371 Strategy::RoundRobin
372 );
373 assert_eq!(
374 plugin_with(Some("least-conn"), "load_balancer", 2)
375 .balancer
376 .strategy(),
377 Strategy::LeastConnections
378 );
379 assert_eq!(
380 plugin_with(Some("ip_hash"), "load_balancing", 2)
381 .balancer
382 .strategy(),
383 Strategy::IpHash
384 );
385 assert_eq!(
387 plugin_with(None, "load_balancing", 2).balancer.strategy(),
388 Strategy::RoundRobin
389 );
390 }
391
392 #[test]
393 fn test_load_balancing_rejects_unknown() {
394 let mut config = HashMap::new();
395 config.insert(
396 "targets".to_string(),
397 serde_json::json!([{ "host": "backend", "port": 3000 }]),
398 );
399 config.insert(
400 "load_balancing".to_string(),
401 serde_json::Value::String("random".to_string()),
402 );
403 assert!(UpstreamPlugin::from_config(&config, &PluginResources::empty()).is_err());
404 }
405
406 #[tokio::test]
407 async fn test_websocket_branch_stashes_target_and_101() {
408 use crate::context::GatewayRequest;
409
410 let plugin = plugin_with(None, "load_balancing", 1);
411 let mut req_headers = HashMap::new();
412 req_headers.insert("upgrade".to_string(), vec!["websocket".to_string()]);
413 let ctx = Context::new(GatewayRequest {
414 method: "GET".into(),
415 path: "/ws/chat".into(),
416 host: "h".into(),
417 scheme: "http".into(),
418 headers: req_headers,
419 query_params: HashMap::new(),
420 body: bytes::Bytes::new(),
421 remote_addr: "1.2.3.4:5".into(),
422 protocol: Protocol::WebSocket,
423 });
424
425 let out = plugin.execute(ctx, &HashMap::new()).await.unwrap();
428 assert_eq!(out.context.response.status_code, 101);
429 assert_eq!(
430 out.context.message.get("__ws_upstream_host").unwrap(),
431 "backend-0"
432 );
433 assert_eq!(out.context.message.get("__ws_upstream_port").unwrap(), 3000);
434 assert_eq!(
435 out.context.message.get("__ws_upstream_path").unwrap(),
436 "/ws/chat"
437 );
438 assert_eq!(plugin.balancer.in_flight_count(0), 0);
440 assert_eq!(out.context.message.get("__ws_upstream_tls").unwrap(), false);
442 assert_eq!(
443 out.context.message.get("__ws_upstream_verify").unwrap(),
444 true
445 );
446 }
447
448 #[test]
449 fn test_tls_config_parses_and_defaults() {
450 let default = plugin_with(None, "load_balancing", 1);
452 assert!(!default.tls);
453 assert!(default.ssl_verify);
454
455 let mut config = HashMap::new();
457 config.insert(
458 "targets".to_string(),
459 serde_json::json!([{ "host": "backend", "port": 443 }]),
460 );
461 config.insert("tls".to_string(), serde_json::json!(true));
462 config.insert("ssl_verify".to_string(), serde_json::json!(false));
463 let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
464 assert!(plugin.tls);
465 assert!(!plugin.ssl_verify);
466 }
467
468 fn write_identity(tag: &str) -> (String, String, String) {
469 let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
472 ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
473 let ca_key = rcgen::KeyPair::generate().unwrap();
474 let ca_cert = ca_params.self_signed(&ca_key).unwrap();
475 let leaf_params = rcgen::CertificateParams::new(vec!["client".to_string()]).unwrap();
476 let leaf_key = rcgen::KeyPair::generate().unwrap();
477 let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_cert, &ca_key).unwrap();
478 let dir = std::env::temp_dir();
479 let pid = std::process::id();
480 let cert = dir.join(format!("featherbit_up_{}_{}.crt", tag, pid));
481 let key = dir.join(format!("featherbit_up_{}_{}.key", tag, pid));
482 let ca = dir.join(format!("featherbit_up_{}_{}.ca.crt", tag, pid));
483 std::fs::write(&cert, leaf_cert.pem()).unwrap();
484 std::fs::write(&key, leaf_key.serialize_pem()).unwrap();
485 std::fs::write(&ca, ca_cert.pem()).unwrap();
486 (
487 cert.to_str().unwrap().to_string(),
488 key.to_str().unwrap().to_string(),
489 ca.to_str().unwrap().to_string(),
490 )
491 }
492
493 fn mtls_config() -> HashMap<String, serde_json::Value> {
495 let mut config = HashMap::new();
496 config.insert(
497 "targets".to_string(),
498 serde_json::json!([{"host": "backend", "port": 443}]),
499 );
500 config
501 }
502
503 #[test]
504 fn test_mtls_config_requires_tls_true() {
505 let (cert, key, _) = write_identity("needstls");
506 let mut config = mtls_config();
507 config.insert("client_cert_path".to_string(), serde_json::json!(cert));
508 config.insert("client_key_path".to_string(), serde_json::json!(key));
509 let err = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
511 assert!(err.contains("tls"), "err was: {}", err);
512 }
513
514 #[test]
515 fn test_mtls_config_cert_and_key_must_pair() {
516 let (cert, _, _) = write_identity("pair");
517 let mut config = mtls_config();
518 config.insert("tls".to_string(), serde_json::json!(true));
519 config.insert("client_cert_path".to_string(), serde_json::json!(cert));
520 let err = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
521 assert!(err.contains("together"), "err was: {}", err);
522 }
523
524 #[test]
525 fn test_mtls_config_ca_with_no_verify_rejected() {
526 let (_, _, ca) = write_identity("contradiction");
527 let mut config = mtls_config();
528 config.insert("tls".to_string(), serde_json::json!(true));
529 config.insert("ssl_verify".to_string(), serde_json::json!(false));
530 config.insert("ca_cert_path".to_string(), serde_json::json!(ca));
531 assert!(UpstreamPlugin::from_config(&config, &PluginResources::empty()).is_err());
532 }
533
534 #[test]
535 fn test_mtls_config_loads_identity_and_registers() {
536 let (cert, key, ca) = write_identity("loads");
537 let mut config = mtls_config();
538 config.insert("tls".to_string(), serde_json::json!(true));
539 config.insert("client_cert_path".to_string(), serde_json::json!(cert));
540 config.insert("client_key_path".to_string(), serde_json::json!(key));
541 config.insert("ca_cert_path".to_string(), serde_json::json!(ca));
542 let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
543 let id = plugin.tls_identity.as_ref().expect("identity loaded");
544 assert!(crate::outbound::tls::UpstreamTls::lookup(id.cache_key()).is_some());
546 }
547
548 #[test]
549 fn test_mtls_config_absent_means_no_identity() {
550 let mut config = mtls_config();
551 config.insert("tls".to_string(), serde_json::json!(true));
552 let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
553 assert!(plugin.tls_identity.is_none());
554 }
555
556 #[test]
557 fn test_mtls_config_non_string_keys_rejected() {
558 for key in ["client_cert_path", "client_key_path", "ca_cert_path"] {
559 for bad_value in [
560 serde_json::json!(123),
561 serde_json::json!(true),
562 serde_json::json!(["x"]),
563 ] {
564 let mut config = mtls_config();
565 config.insert("tls".to_string(), serde_json::json!(true));
566 config.insert(key.to_string(), bad_value.clone());
567 let err =
568 UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
569 assert!(
570 err.contains(&format!("{} must be a string", key)),
571 "key {} value {:?} produced err: {}",
572 key,
573 bad_value,
574 err
575 );
576 }
577 }
578 }
579}