Skip to main content

featherbit/plugins/native/
upstream.rs

1//! The `upstream` node — forwards the request to a backend target over HTTP,
2//! with round-robin, least-connections, or IP-hash load balancing across the
3//! configured targets, and writes the backend's reply into `Context.response`.
4
5use 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
16/// Proxies the request to one of the configured backend targets and populates
17/// `Context.response` with the upstream's status, headers, and body.
18///
19/// Connection failures, request-build failures, and body-read failures are
20/// returned as [`PluginExecutionError`]s so the graph engine can route them
21/// through this node's error port.
22pub struct UpstreamPlugin {
23    /// Backend pool + load-balancing strategy (shared with the L4 stream proxy).
24    balancer: Balancer,
25    /// Shared pooled HTTP client (from `PluginResources`).
26    client: Arc<OutboundClient>,
27    /// Whole-call deadline per proxied request.
28    timeout: Duration,
29    /// Connect to the upstream over TLS (`https`/`wss`); default false.
30    tls: bool,
31    /// Verify the upstream's TLS certificate; default true. Only meaningful
32    /// when `tls` is set.
33    ssl_verify: bool,
34    /// Per-upstream TLS identity (client cert / private CA); None = shared
35    /// clients, exactly the pre-mTLS behavior.
36    tls_identity: Option<Arc<crate::outbound::tls::UpstreamTls>>,
37}
38
39// Manual `Debug`, scoped to what's useful in test/error output: `balancer`
40// and `client` don't implement it (pooled hyper client, atomics-backed pool
41// state), so a derive isn't available here.
42impl 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    /// Builds the plugin from node config.
55    ///
56    /// Accepted keys:
57    /// - `targets` (array of `{host: string, port: integer}`, **required**):
58    ///   the backend pool. Entries missing `host` or `port` are skipped;
59    ///   an empty resulting pool is a config error.
60    /// - `load_balancing` (string, default `round_robin`): one of
61    ///   `round_robin`, `least_connections`, or `ip_hash`. Hyphenated and
62    ///   short spellings (`round-robin`, `least-conn`) are accepted, as is
63    ///   the legacy key name `load_balancer` (see [`Strategy::parse`]).
64    ///
65    /// - `timeout_ms` (integer, default `60000`): whole-call deadline
66    ///   (connect + request + response body) per proxied request; exceeding
67    ///   it fails the node with `UPSTREAM_TIMEOUT` through the error port.
68    /// - `tls` (bool, default `false`): connect to the upstream over TLS
69    ///   (`https` for the buffered path, `wss` for WebSocket).
70    /// - `ssl_verify` (bool, default `true`): verify the upstream's TLS
71    ///   certificate. Only meaningful when `tls` is set.
72    /// - `client_cert_path` / `client_key_path` (string, optional): PEM
73    ///   client certificate and private key presented to the upstream for
74    ///   mutual TLS. Must be set together, and only with `tls: true`.
75    /// - `ca_cert_path` (string, optional): PEM CA bundle used to verify the
76    ///   upstream's certificate, *replacing* the native root store for this
77    ///   upstream. Requires `tls: true`; rejected together with
78    ///   `ssl_verify: false` (a CA bundle to verify with is contradictory
79    ///   when verification is off).
80    ///
81    /// Errors if no valid target is configured, if the load-balancing value
82    /// is not a string, if it names an unknown strategy, if any mTLS key is
83    /// set without `tls: true`, if `client_cert_path`/`client_key_path` are
84    /// not both set, if `ca_cert_path` is set with `ssl_verify: false`, or if
85    /// the configured cert/key/CA files can't be read or parsed.
86    ///
87    /// ```yaml
88    /// type: upstream
89    /// config:
90    ///   targets:
91    ///     - host: backend-1
92    ///       port: 3000
93    ///     - host: backend-2
94    ///       port: 3000
95    ///   load_balancing: least_connections
96    ///   timeout_ms: 60000
97    ///   tls: true
98    ///   client_cert_path: /etc/gateway/client.crt
99    ///   client_key_path: /etc/gateway/client.key
100    ///   ca_cert_path: /etc/gateway/ca.crt
101    /// ```
102    pub fn from_config(
103        config: &HashMap<String, serde_json::Value>,
104        resources: &Arc<PluginResources>,
105    ) -> Result<Self, String> {
106        // Tolerant parse: entries missing `host`/`port` are silently skipped
107        // (an empty resulting pool is rejected by `Balancer::new`).
108        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        // `load_balancing` is the canonical key; `load_balancer` is accepted
124        // because earlier UI builds saved configs under that name.
125        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        // A CA bundle exists to *verify* the upstream; pairing it with
178        // ssl_verify:false is contradictory, so reject rather than guess.
179        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        // WebSocket upgrade: don't do a buffered round-trip. Resolve the target
222        // (load balancing works the same — selection is pure) and stash it for
223        // the listener, which owns the raw connection and performs the upstream
224        // handshake + bidirectional relay. Signal intent with 101. The in-flight
225        // counter is intentionally skipped: a WS tunnel outlives this node, so
226        // there is no round-trip lifecycle to bound it.
227        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        // Forward request headers, overriding Host with the upstream target.
269        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        // Populate context.response from the upstream response
330        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        // canonical key, spec spelling
360        assert_eq!(
361            plugin_with(Some("least_connections"), "load_balancing", 2)
362                .balancer
363                .strategy(),
364            Strategy::LeastConnections
365        );
366        // legacy UI key and hyphenated/short spellings
367        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        // absent -> default
386        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        // No target is reachable, but the WS branch must NOT do a round-trip —
426        // it resolves the target and returns a 101 without any network call.
427        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        // The in-flight counter was not touched for the WS path.
439        assert_eq!(plugin.balancer.in_flight_count(0), 0);
440        // TLS flags default to plaintext + verify-on.
441        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        // Defaults: plaintext, verify on.
451        let default = plugin_with(None, "load_balancing", 1);
452        assert!(!default.tls);
453        assert!(default.ssl_verify);
454
455        // Explicit tls + ssl_verify:false.
456        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        // Same helper as src/outbound/tls.rs tests: CA + leaf, PEM files in
470        // temp_dir, returns (cert_path, key_path, ca_path).
471        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    /// Base config with one target; callers add TLS keys.
494    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        // tls defaults to false -> config error.
510        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        // Registered for the WebSocket relay to look up.
545        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}