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 bytes::Bytes;
11
12use crate::balancer::{Balancer, Strategy, Target};
13use crate::context::stream::ResponseStream;
14use crate::context::{Context, GatewayError, Protocol};
15use crate::outbound::idle::{body_holding, idle_timeout_body};
16use crate::outbound::{OutboundClient, OutboundError, OutboundRequest};
17use crate::plugins::resources::PluginResources;
18use crate::plugins::{Plugin, PluginExecutionError, PluginOutput, PluginResult};
19
20/// Proxies the request to one of the configured backend targets and populates
21/// `Context.response` with the upstream's status, headers, and body.
22///
23/// Connection failures, request-build failures, and body-read failures are
24/// returned as [`PluginExecutionError`]s so the graph engine can route them
25/// through this node's error port.
26pub struct UpstreamPlugin {
27    /// Backend pool + load-balancing strategy (shared with the L4 stream proxy).
28    /// `Arc`-wrapped so the streaming branch can take an `owned_acquire`
29    /// in-flight guard that outlives this call's stack frame — it travels
30    /// with the response stream, released only when the body finishes.
31    balancer: Arc<Balancer>,
32    /// Shared pooled HTTP client (from `PluginResources`).
33    client: Arc<OutboundClient>,
34    /// Whole-call deadline per proxied request.
35    timeout: Duration,
36    /// Idle bound on a streaming response body: no frame for this long and
37    /// the stream is reaped. Only consulted when `__may_stream` is set.
38    stream_idle_timeout: Duration,
39    /// Connect to the upstream over TLS (`https`/`wss`); default false.
40    tls: bool,
41    /// Verify the upstream's TLS certificate; default true. Only meaningful
42    /// when `tls` is set.
43    ssl_verify: bool,
44    /// Per-upstream TLS identity (client cert / private CA); None = shared
45    /// clients, exactly the pre-mTLS behavior.
46    tls_identity: Option<Arc<crate::outbound::tls::UpstreamTls>>,
47}
48
49// Manual `Debug`, scoped to what's useful in test/error output: `balancer`
50// and `client` don't implement it (pooled hyper client, atomics-backed pool
51// state), so a derive isn't available here.
52impl std::fmt::Debug for UpstreamPlugin {
53    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
54        f.debug_struct("UpstreamPlugin")
55            .field("timeout", &self.timeout)
56            .field("tls", &self.tls)
57            .field("ssl_verify", &self.ssl_verify)
58            .field("tls_identity_set", &self.tls_identity.is_some())
59            .finish()
60    }
61}
62
63impl UpstreamPlugin {
64    /// Builds the plugin from node config.
65    ///
66    /// Accepted keys:
67    /// - `targets` (array of `{host: string, port: integer}`, **required**):
68    ///   the backend pool. Entries missing `host` or `port` are skipped;
69    ///   an empty resulting pool is a config error.
70    /// - `load_balancing` (string, default `round_robin`): one of
71    ///   `round_robin`, `least_connections`, or `ip_hash`. Hyphenated and
72    ///   short spellings (`round-robin`, `least-conn`) are accepted, as is
73    ///   the legacy key name `load_balancer` (see [`Strategy::parse`]).
74    ///
75    /// - `timeout_ms` (integer, default `60000`): whole-call deadline
76    ///   (connect + request + response body) per proxied request; exceeding
77    ///   it fails the node with `UPSTREAM_TIMEOUT` through the error port.
78    ///   For a streaming response (see `__may_stream` on
79    ///   [`crate::graph::engine`]) this bounds connect + request + response
80    ///   headers only — the body is then bounded by `stream_idle_timeout_ms`
81    ///   instead.
82    /// - `stream_idle_timeout_ms` (integer, default `60000`): only consulted
83    ///   when the node is permitted to stream; no frame arriving on the
84    ///   response body for this long reaps the stream. Resets on every
85    ///   frame, so a steady stream survives indefinitely.
86    /// - `tls` (bool, default `false`): connect to the upstream over TLS
87    ///   (`https` for the buffered path, `wss` for WebSocket).
88    /// - `ssl_verify` (bool, default `true`): verify the upstream's TLS
89    ///   certificate. Only meaningful when `tls` is set.
90    /// - `client_cert_path` / `client_key_path` (string, optional): PEM
91    ///   client certificate and private key presented to the upstream for
92    ///   mutual TLS. Must be set together, and only with `tls: true`.
93    /// - `ca_cert_path` (string, optional): PEM CA bundle used to verify the
94    ///   upstream's certificate, *replacing* the native root store for this
95    ///   upstream. Requires `tls: true`; rejected together with
96    ///   `ssl_verify: false` (a CA bundle to verify with is contradictory
97    ///   when verification is off).
98    ///
99    /// Errors if no valid target is configured, if the load-balancing value
100    /// is not a string, if it names an unknown strategy, if any mTLS key is
101    /// set without `tls: true`, if `client_cert_path`/`client_key_path` are
102    /// not both set, if `ca_cert_path` is set with `ssl_verify: false`, or if
103    /// the configured cert/key/CA files can't be read or parsed.
104    ///
105    /// ```yaml
106    /// type: upstream
107    /// config:
108    ///   targets:
109    ///     - host: backend-1
110    ///       port: 3000
111    ///     - host: backend-2
112    ///       port: 3000
113    ///   load_balancing: least_connections
114    ///   timeout_ms: 60000
115    ///   tls: true
116    ///   client_cert_path: /etc/gateway/client.crt
117    ///   client_key_path: /etc/gateway/client.key
118    ///   ca_cert_path: /etc/gateway/ca.crt
119    /// ```
120    pub fn from_config(
121        config: &HashMap<String, serde_json::Value>,
122        resources: &Arc<PluginResources>,
123    ) -> Result<Self, String> {
124        // Tolerant parse: entries missing `host`/`port` are silently skipped
125        // (an empty resulting pool is rejected by `Balancer::new`).
126        let targets = config
127            .get("targets")
128            .and_then(|v| v.as_array())
129            .map(|seq| {
130                seq.iter()
131                    .filter_map(|t| {
132                        let mapping = t.as_object()?;
133                        let host = mapping.get("host")?.as_str()?.to_string();
134                        let port = mapping.get("port")?.as_u64()? as u16;
135                        Some(Target { host, port })
136                    })
137                    .collect::<Vec<_>>()
138            })
139            .unwrap_or_default();
140
141        // `load_balancing` is the canonical key; `load_balancer` is accepted
142        // because earlier UI builds saved configs under that name.
143        let strategy = match config
144            .get("load_balancing")
145            .or_else(|| config.get("load_balancer"))
146        {
147            None => Strategy::default(),
148            Some(v) => {
149                let s = v
150                    .as_str()
151                    .ok_or_else(|| "load_balancing must be a string".to_string())?;
152                Strategy::parse(s)?
153            }
154        };
155
156        let balancer = Arc::new(Balancer::new(targets, strategy)?);
157
158        let timeout = Duration::from_millis(
159            config
160                .get("timeout_ms")
161                .and_then(|v| v.as_u64())
162                .unwrap_or(60_000),
163        );
164        let stream_idle_timeout = Duration::from_millis(
165            config
166                .get("stream_idle_timeout_ms")
167                .and_then(|v| v.as_u64())
168                .unwrap_or(60_000),
169        );
170
171        let tls = config.get("tls").and_then(|v| v.as_bool()).unwrap_or(false);
172        let ssl_verify = config
173            .get("ssl_verify")
174            .and_then(|v| v.as_bool())
175            .unwrap_or(true);
176
177        let string_config_value = |key: &str| -> Result<Option<String>, String> {
178            match config.get(key) {
179                None => Ok(None),
180                Some(v) => match v.as_str() {
181                    Some(s) => Ok(Some(s.to_string())),
182                    None => Err(format!("{} must be a string", key)),
183                },
184            }
185        };
186
187        let client_cert_path = string_config_value("client_cert_path")?;
188        let client_key_path = string_config_value("client_key_path")?;
189        let ca_cert_path = string_config_value("ca_cert_path")?;
190
191        let any_mtls_key =
192            client_cert_path.is_some() || client_key_path.is_some() || ca_cert_path.is_some();
193        if any_mtls_key && !tls {
194            return Err(
195                "client_cert_path/client_key_path/ca_cert_path require tls: true".to_string(),
196            );
197        }
198        if client_cert_path.is_some() != client_key_path.is_some() {
199            return Err("client_cert_path and client_key_path must be set together".to_string());
200        }
201        // A CA bundle exists to *verify* the upstream; pairing it with
202        // ssl_verify:false is contradictory, so reject rather than guess.
203        if ca_cert_path.is_some() && !ssl_verify {
204            return Err("ca_cert_path with ssl_verify: false is contradictory".to_string());
205        }
206
207        let tls_identity = if any_mtls_key {
208            let client = client_cert_path.as_deref().zip(client_key_path.as_deref());
209            let identity = crate::outbound::tls::UpstreamTls::load(
210                client,
211                ca_cert_path.as_deref(),
212                ssl_verify,
213            )?;
214            crate::outbound::tls::UpstreamTls::register(&identity);
215            Some(identity)
216        } else {
217            None
218        };
219
220        Ok(Self {
221            balancer,
222            client: resources.outbound.clone(),
223            timeout,
224            stream_idle_timeout,
225            tls,
226            ssl_verify,
227            tls_identity,
228        })
229    }
230
231    /// Builds the [`OutboundRequest`] to send to `target`: method, URL,
232    /// forwarded headers (Host overridden to the target), body, timeout, and
233    /// TLS settings. Shared by the buffered and streaming branches of
234    /// `execute` so they can't drift — every field here (ssl_verify, tls
235    /// identity, timeout) affects both equally.
236    fn outbound_request(
237        &self,
238        ctx: &Context,
239        uri: String,
240        method: http::Method,
241        target: &Target,
242    ) -> OutboundRequest {
243        OutboundRequest {
244            method,
245            url: uri,
246            headers: forwarded_headers(ctx, target),
247            body: ctx.request.body.clone(),
248            timeout: self.timeout,
249            ssl_verify: self.ssl_verify,
250            tls: self.tls_identity.clone(),
251        }
252    }
253}
254
255/// Forwards the request's headers to the upstream, overriding `Host` with
256/// the selected target. Shared by both `execute` branches via
257/// [`UpstreamPlugin::outbound_request`].
258fn forwarded_headers(ctx: &Context, target: &Target) -> Vec<(String, String)> {
259    let mut headers: Vec<(String, String)> = Vec::new();
260    for (key, values) in &ctx.request.headers {
261        if key.eq_ignore_ascii_case("host") {
262            continue;
263        }
264        for value in values {
265            headers.push((key.clone(), value.clone()));
266        }
267    }
268    headers.push((
269        "host".to_string(),
270        format!("{}:{}", target.host, target.port),
271    ));
272    headers
273}
274
275/// The outbound request-target: the request path plus the rebuilt query
276/// string when the request carried one.
277///
278/// `query_params` holds values exactly as received — ingress splits the raw
279/// query on `&`/`=` without percent-decoding — so rebuilding is lossless.
280/// Parameter order is normalized (sorted) because the original order is
281/// already lost in the `HashMap` at ingress; sorting at least makes the
282/// outbound target deterministic.
283fn request_target(ctx: &Context) -> String {
284    let query = crate::vars::query_string(ctx);
285    if query.is_empty() {
286        ctx.request.path.clone()
287    } else {
288        format!("{}?{}", ctx.request.path, query)
289    }
290}
291
292/// Maps an [`OutboundError`] to the `(code, message)` pair used on this
293/// node's error port. Shared by the buffered and streaming branches so a fix
294/// to one (e.g. a wording or timeout-classification change) can't land in
295/// only one of them.
296fn map_outbound_error(e: OutboundError, target: &Target) -> (&'static str, String) {
297    match &e {
298        OutboundError::Timeout(d) => (
299            "UPSTREAM_TIMEOUT",
300            format!(
301                "Upstream {}:{} timed out after {:?}",
302                target.host, target.port, d
303            ),
304        ),
305        OutboundError::InvalidRequest(m) => (
306            "UPSTREAM_REQUEST_BUILD_ERROR",
307            format!("Failed to build upstream request: {}", m),
308        ),
309        OutboundError::Transport(m) => (
310            "UPSTREAM_CONNECTION_ERROR",
311            format!(
312                "Failed to reach upstream {}:{}: {}",
313                target.host, target.port, m
314            ),
315        ),
316    }
317}
318
319#[async_trait]
320impl Plugin for UpstreamPlugin {
321    fn plugin_type(&self) -> &str {
322        "upstream"
323    }
324
325    async fn execute(&self, mut ctx: Context) -> PluginResult {
326        let target_idx = self.balancer.select(&ctx.request.remote_addr);
327        let target = self.balancer.target(target_idx);
328
329        // WebSocket upgrade: don't do a buffered round-trip. Resolve the target
330        // (load balancing works the same — selection is pure) and stash it for
331        // the listener, which owns the raw connection and performs the upstream
332        // handshake + bidirectional relay. Signal intent with 101. The in-flight
333        // counter is intentionally skipped: a WS tunnel outlives this node, so
334        // there is no round-trip lifecycle to bound it.
335        if ctx.request.protocol == Protocol::WebSocket {
336            // Computed before the first `ctx.message` mutable borrow below.
337            let ws_target = request_target(&ctx);
338            ctx.message.insert(
339                "__ws_upstream_host".to_string(),
340                serde_json::json!(target.host),
341            );
342            ctx.message.insert(
343                "__ws_upstream_port".to_string(),
344                serde_json::json!(target.port),
345            );
346            ctx.message.insert(
347                "__ws_upstream_path".to_string(),
348                serde_json::json!(ws_target),
349            );
350            ctx.message
351                .insert("__ws_upstream_tls".to_string(), serde_json::json!(self.tls));
352            ctx.message.insert(
353                "__ws_upstream_verify".to_string(),
354                serde_json::json!(self.ssl_verify),
355            );
356            if let Some(identity) = &self.tls_identity {
357                ctx.message.insert(
358                    "__ws_upstream_tls_key".to_string(),
359                    serde_json::json!(identity.cache_key()),
360                );
361            }
362            ctx.response.status_code = 101;
363            return Ok(PluginOutput::success(ctx));
364        }
365
366        let scheme = if self.tls { "https" } else { "http" };
367        let uri = format!(
368            "{}://{}:{}{}",
369            scheme,
370            target.host,
371            target.port,
372            request_target(&ctx)
373        );
374
375        let method: http::Method = ctx.request.method.parse().unwrap_or(http::Method::GET);
376
377        let may_stream = ctx
378            .message
379            .get("__may_stream")
380            .and_then(|v| v.as_bool())
381            .unwrap_or(false);
382
383        if may_stream {
384            // Owned, not borrowed: this guard must outlive `execute`'s stack
385            // frame — it travels with the response stream and is released
386            // only when the body finishes, not when this node returns.
387            let guard = self.balancer.owned_acquire(target_idx);
388            let outbound = self.outbound_request(&ctx, uri, method, target);
389            return match self.client.request_streaming(outbound).await {
390                Ok(resp) => {
391                    ctx.response.status_code = resp.status;
392                    ctx.response.headers = resp.headers;
393                    // Invariant: exactly one of `body`/`stream` carries
394                    // content. This node is about to populate `stream`.
395                    ctx.response.body = Bytes::new();
396                    // The guard is bound into the body at construction, not
397                    // attached after via `stream.hold(...)`: `into_parts`
398                    // hands body and guards back as two independent values,
399                    // so keeping them together end-to-end from the moment
400                    // the stream exists removes any chance of the transport
401                    // task dropping one half and not the other.
402                    let idled = idle_timeout_body(resp.body, self.stream_idle_timeout);
403                    let held = body_holding(idled, vec![Box::new(guard)]);
404                    ctx.response.stream = Some(ResponseStream::new(held));
405                    Ok(PluginOutput::success(ctx))
406                }
407                Err(e) => {
408                    // No byte has reached the client yet (the deadline covers
409                    // only connect + request + response headers), so this is
410                    // an ordinary error-port exit exactly like the buffered
411                    // path's failure below.
412                    let (code, message) = map_outbound_error(e, target);
413                    Err(PluginExecutionError {
414                        context: ctx,
415                        error: GatewayError {
416                            node_id: String::new(),
417                            code: code.to_string(),
418                            message,
419                            metadata: HashMap::new(),
420                        },
421                    })
422                }
423            };
424        }
425
426        let _in_flight_guard = self.balancer.acquire(target_idx);
427        let outbound = self.outbound_request(&ctx, uri, method, target);
428
429        let response = match self.client.request(outbound).await {
430            Ok(resp) => resp,
431            Err(e) => {
432                let (code, message) = map_outbound_error(e, target);
433                let error = GatewayError {
434                    node_id: String::new(),
435                    code: code.to_string(),
436                    message,
437                    metadata: HashMap::new(),
438                };
439                return Err(PluginExecutionError {
440                    context: ctx,
441                    error,
442                });
443            }
444        };
445
446        // Populate context.response from the upstream response
447        ctx.response.status_code = response.status;
448        ctx.response.headers = response.headers;
449        ctx.response.body = response.body;
450        // Invariant, enforced locally rather than argued globally: a
451        // buffered response never carries a stream. Matters on a failover
452        // policy shape where an earlier node's context (including a stale
453        // `response.stream` from some prior hop) reaches this node.
454        ctx.response.stream = None;
455
456        Ok(PluginOutput::success(ctx))
457    }
458}
459
460#[cfg(test)]
461mod tests {
462    use super::*;
463
464    fn plugin_with(strategy: Option<&str>, key: &str, n_targets: usize) -> UpstreamPlugin {
465        let targets: Vec<serde_json::Value> = (0..n_targets)
466            .map(|i| serde_json::json!({ "host": format!("backend-{}", i), "port": 3000 }))
467            .collect();
468        let mut config = HashMap::new();
469        config.insert("targets".to_string(), serde_json::Value::Array(targets));
470        if let Some(s) = strategy {
471            config.insert(key.to_string(), serde_json::Value::String(s.to_string()));
472        }
473        UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap()
474    }
475
476    #[test]
477    fn test_load_balancing_parsing_and_aliases() {
478        // canonical key, spec spelling
479        assert_eq!(
480            plugin_with(Some("least_connections"), "load_balancing", 2)
481                .balancer
482                .strategy(),
483            Strategy::LeastConnections
484        );
485        // legacy UI key and hyphenated/short spellings
486        assert_eq!(
487            plugin_with(Some("round-robin"), "load_balancer", 2)
488                .balancer
489                .strategy(),
490            Strategy::RoundRobin
491        );
492        assert_eq!(
493            plugin_with(Some("least-conn"), "load_balancer", 2)
494                .balancer
495                .strategy(),
496            Strategy::LeastConnections
497        );
498        assert_eq!(
499            plugin_with(Some("ip_hash"), "load_balancing", 2)
500                .balancer
501                .strategy(),
502            Strategy::IpHash
503        );
504        // absent -> default
505        assert_eq!(
506            plugin_with(None, "load_balancing", 2).balancer.strategy(),
507            Strategy::RoundRobin
508        );
509    }
510
511    #[test]
512    fn test_load_balancing_rejects_unknown() {
513        let mut config = HashMap::new();
514        config.insert(
515            "targets".to_string(),
516            serde_json::json!([{ "host": "backend", "port": 3000 }]),
517        );
518        config.insert(
519            "load_balancing".to_string(),
520            serde_json::Value::String("random".to_string()),
521        );
522        assert!(UpstreamPlugin::from_config(&config, &PluginResources::empty()).is_err());
523    }
524
525    #[tokio::test]
526    async fn test_websocket_branch_stashes_target_and_101() {
527        use crate::context::GatewayRequest;
528
529        let plugin = plugin_with(None, "load_balancing", 1);
530        let mut req_headers = HashMap::new();
531        req_headers.insert("upgrade".to_string(), vec!["websocket".to_string()]);
532        let ctx = Context::new(GatewayRequest {
533            method: "GET".into(),
534            path: "/ws/chat".into(),
535            host: "h".into(),
536            scheme: "http".into(),
537            headers: req_headers,
538            query_params: HashMap::new(),
539            body: bytes::Bytes::new(),
540            remote_addr: "1.2.3.4:5".into(),
541            protocol: Protocol::WebSocket,
542        });
543
544        // No target is reachable, but the WS branch must NOT do a round-trip —
545        // it resolves the target and returns a 101 without any network call.
546        let out = plugin.execute(ctx).await.unwrap();
547        assert_eq!(out.context.response.status_code, 101);
548        assert_eq!(
549            out.context.message.get("__ws_upstream_host").unwrap(),
550            "backend-0"
551        );
552        assert_eq!(out.context.message.get("__ws_upstream_port").unwrap(), 3000);
553        assert_eq!(
554            out.context.message.get("__ws_upstream_path").unwrap(),
555            "/ws/chat"
556        );
557        // The in-flight counter was not touched for the WS path.
558        assert_eq!(plugin.balancer.in_flight_count(0), 0);
559        // TLS flags default to plaintext + verify-on.
560        assert_eq!(out.context.message.get("__ws_upstream_tls").unwrap(), false);
561        assert_eq!(
562            out.context.message.get("__ws_upstream_verify").unwrap(),
563            true
564        );
565    }
566
567    #[test]
568    fn test_tls_config_parses_and_defaults() {
569        // Defaults: plaintext, verify on.
570        let default = plugin_with(None, "load_balancing", 1);
571        assert!(!default.tls);
572        assert!(default.ssl_verify);
573
574        // Explicit tls + ssl_verify:false.
575        let mut config = HashMap::new();
576        config.insert(
577            "targets".to_string(),
578            serde_json::json!([{ "host": "backend", "port": 443 }]),
579        );
580        config.insert("tls".to_string(), serde_json::json!(true));
581        config.insert("ssl_verify".to_string(), serde_json::json!(false));
582        let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
583        assert!(plugin.tls);
584        assert!(!plugin.ssl_verify);
585    }
586
587    fn write_identity(tag: &str) -> (String, String, String) {
588        // Same helper as src/outbound/tls.rs tests: CA + leaf, PEM files in
589        // temp_dir, returns (cert_path, key_path, ca_path).
590        let mut ca_params = rcgen::CertificateParams::new(Vec::<String>::new()).unwrap();
591        ca_params.is_ca = rcgen::IsCa::Ca(rcgen::BasicConstraints::Unconstrained);
592        let ca_key = rcgen::KeyPair::generate().unwrap();
593        let ca_cert = ca_params.self_signed(&ca_key).unwrap();
594        let ca_issuer = rcgen::Issuer::from_ca_cert_der(ca_cert.der(), &ca_key).unwrap();
595        let leaf_params = rcgen::CertificateParams::new(vec!["client".to_string()]).unwrap();
596        let leaf_key = rcgen::KeyPair::generate().unwrap();
597        let leaf_cert = leaf_params.signed_by(&leaf_key, &ca_issuer).unwrap();
598        let dir = std::env::temp_dir();
599        let pid = std::process::id();
600        let cert = dir.join(format!("featherbit_up_{}_{}.crt", tag, pid));
601        let key = dir.join(format!("featherbit_up_{}_{}.key", tag, pid));
602        let ca = dir.join(format!("featherbit_up_{}_{}.ca.crt", tag, pid));
603        std::fs::write(&cert, leaf_cert.pem()).unwrap();
604        std::fs::write(&key, leaf_key.serialize_pem()).unwrap();
605        std::fs::write(&ca, ca_cert.pem()).unwrap();
606        (
607            cert.to_str().unwrap().to_string(),
608            key.to_str().unwrap().to_string(),
609            ca.to_str().unwrap().to_string(),
610        )
611    }
612
613    /// Base config with one target; callers add TLS keys.
614    fn mtls_config() -> HashMap<String, serde_json::Value> {
615        let mut config = HashMap::new();
616        config.insert(
617            "targets".to_string(),
618            serde_json::json!([{"host": "backend", "port": 443}]),
619        );
620        config
621    }
622
623    #[test]
624    fn test_mtls_config_requires_tls_true() {
625        let (cert, key, _) = write_identity("needstls");
626        let mut config = mtls_config();
627        config.insert("client_cert_path".to_string(), serde_json::json!(cert));
628        config.insert("client_key_path".to_string(), serde_json::json!(key));
629        // tls defaults to false -> config error.
630        let err = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
631        assert!(err.contains("tls"), "err was: {}", err);
632    }
633
634    #[test]
635    fn test_mtls_config_cert_and_key_must_pair() {
636        let (cert, _, _) = write_identity("pair");
637        let mut config = mtls_config();
638        config.insert("tls".to_string(), serde_json::json!(true));
639        config.insert("client_cert_path".to_string(), serde_json::json!(cert));
640        let err = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
641        assert!(err.contains("together"), "err was: {}", err);
642    }
643
644    #[test]
645    fn test_mtls_config_ca_with_no_verify_rejected() {
646        let (_, _, ca) = write_identity("contradiction");
647        let mut config = mtls_config();
648        config.insert("tls".to_string(), serde_json::json!(true));
649        config.insert("ssl_verify".to_string(), serde_json::json!(false));
650        config.insert("ca_cert_path".to_string(), serde_json::json!(ca));
651        assert!(UpstreamPlugin::from_config(&config, &PluginResources::empty()).is_err());
652    }
653
654    #[test]
655    fn test_mtls_config_loads_identity_and_registers() {
656        let (cert, key, ca) = write_identity("loads");
657        let mut config = mtls_config();
658        config.insert("tls".to_string(), serde_json::json!(true));
659        config.insert("client_cert_path".to_string(), serde_json::json!(cert));
660        config.insert("client_key_path".to_string(), serde_json::json!(key));
661        config.insert("ca_cert_path".to_string(), serde_json::json!(ca));
662        let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
663        let id = plugin.tls_identity.as_ref().expect("identity loaded");
664        // Registered for the WebSocket relay to look up.
665        assert!(crate::outbound::tls::UpstreamTls::lookup(id.cache_key()).is_some());
666    }
667
668    #[test]
669    fn test_mtls_config_absent_means_no_identity() {
670        let mut config = mtls_config();
671        config.insert("tls".to_string(), serde_json::json!(true));
672        let plugin = UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap();
673        assert!(plugin.tls_identity.is_none());
674    }
675
676    #[test]
677    fn test_mtls_config_non_string_keys_rejected() {
678        for key in ["client_cert_path", "client_key_path", "ca_cert_path"] {
679            for bad_value in [
680                serde_json::json!(123),
681                serde_json::json!(true),
682                serde_json::json!(["x"]),
683            ] {
684                let mut config = mtls_config();
685                config.insert("tls".to_string(), serde_json::json!(true));
686                config.insert(key.to_string(), bad_value.clone());
687                let err =
688                    UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap_err();
689                assert!(
690                    err.contains(&format!("{} must be a string", key)),
691                    "key {} value {:?} produced err: {}",
692                    key,
693                    bad_value,
694                    err
695                );
696            }
697        }
698    }
699
700    /// Minimal one-shot HTTP server that records the request line (method and
701    /// request-target) of the first request it receives and answers `200`
702    /// with a non-empty body — a `content-length: 0` reply would make any
703    /// `body.is_empty()` assertion on the caller's side vacuous (it would
704    /// pass whether or not the plugin actually populated `body`/`stream`
705    /// correctly). Returns its port and a receiver for the captured line.
706    async fn spawn_request_line_capture() -> (u16, tokio::sync::oneshot::Receiver<String>) {
707        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
708        let port = listener.local_addr().unwrap().port();
709        let (tx, rx) = tokio::sync::oneshot::channel();
710        tokio::spawn(async move {
711            if let Ok((mut stream, _)) = listener.accept().await {
712                use tokio::io::{AsyncReadExt, AsyncWriteExt};
713                let mut buf = [0u8; 4096];
714                let n = stream.read(&mut buf).await.unwrap_or(0);
715                let text = String::from_utf8_lossy(&buf[..n]).to_string();
716                let line = text.lines().next().unwrap_or("").to_string();
717                let _ = tx.send(line);
718                let _ = stream
719                    .write_all(
720                        b"HTTP/1.1 200 OK
721content-length: 2
722
723ok",
724                    )
725                    .await;
726                let _ = stream.shutdown().await;
727            }
728        });
729        (port, rx)
730    }
731
732    fn ctx_with_query(path: &str, query: Vec<(&str, Vec<&str>)>) -> Context {
733        use crate::context::GatewayRequest;
734        let query_params: HashMap<String, Vec<String>> = query
735            .into_iter()
736            .map(|(k, vs)| {
737                (
738                    k.to_string(),
739                    vs.into_iter().map(|v| v.to_string()).collect(),
740                )
741            })
742            .collect();
743        Context::new(GatewayRequest {
744            method: "GET".into(),
745            path: path.into(),
746            host: "h".into(),
747            scheme: "http".into(),
748            headers: HashMap::new(),
749            query_params,
750            body: bytes::Bytes::new(),
751            remote_addr: "1.2.3.4:5".into(),
752            protocol: Protocol::Http1,
753        })
754    }
755
756    fn plugin_at(port: u16) -> UpstreamPlugin {
757        let mut config = HashMap::new();
758        config.insert(
759            "targets".to_string(),
760            serde_json::json!([{ "host": "127.0.0.1", "port": port }]),
761        );
762        UpstreamPlugin::from_config(&config, &PluginResources::empty()).unwrap()
763    }
764
765    /// Regression: the outbound request-target must carry the query string.
766    /// Building the URL from `ctx.request.path` alone silently dropped it on
767    /// every proxied call — an OIDC authorize hop reached the IdP with no
768    /// `client_id`, which the IdP reports as "parameter not present".
769    #[tokio::test]
770    async fn test_query_string_is_forwarded_to_upstream() {
771        let (port, rx) = spawn_request_line_capture().await;
772        let ctx = ctx_with_query(
773            "/realms/example/protocol/openid-connect/auth",
774            vec![("client_id", vec!["apisix"])],
775        );
776
777        plugin_at(port).execute(ctx).await.unwrap();
778
779        let request_line = rx.await.unwrap();
780        assert!(
781            request_line.contains("client_id=apisix"),
782            "query string dropped from outbound request-target: {request_line}"
783        );
784    }
785
786    /// Regression: the WebSocket relay target must keep the query string too.
787    /// `__ws_upstream_path` is consumed verbatim by the relay in
788    /// `server::listener`, so dropping the query there breaks token-in-query
789    /// upgrades (`wss://host/ws?token=...`) exactly as it broke plain HTTP.
790    #[tokio::test]
791    async fn test_websocket_upstream_path_keeps_query_string() {
792        use crate::context::GatewayRequest;
793
794        let mut req_headers = HashMap::new();
795        req_headers.insert("upgrade".to_string(), vec!["websocket".to_string()]);
796        let mut query_params = HashMap::new();
797        query_params.insert("token".to_string(), vec!["abc123".to_string()]);
798        let ctx = Context::new(GatewayRequest {
799            method: "GET".into(),
800            path: "/ws/chat".into(),
801            host: "h".into(),
802            scheme: "http".into(),
803            headers: req_headers,
804            query_params,
805            body: bytes::Bytes::new(),
806            remote_addr: "1.2.3.4:5".into(),
807            protocol: Protocol::WebSocket,
808        });
809
810        let out = plugin_with(None, "load_balancing", 1)
811            .execute(ctx)
812            .await
813            .unwrap();
814
815        assert_eq!(
816            out.context.message.get("__ws_upstream_path").unwrap(),
817            "/ws/chat?token=abc123"
818        );
819    }
820
821    /// With `__may_stream` set, the node must hand back a stream rather than a
822    /// buffered body — and must leave `body` empty, per the invariant.
823    #[tokio::test]
824    async fn test_upstream_streams_when_permitted() {
825        let (port, _rx) = spawn_request_line_capture().await;
826        let mut ctx = ctx_with_query("/stream", vec![]);
827        ctx.message
828            .insert("__may_stream".to_string(), serde_json::json!(true));
829
830        let out = plugin_at(port).execute(ctx).await.unwrap();
831
832        assert!(out.context.response.stream.is_some(), "expected a stream");
833        assert!(
834            out.context.response.body.is_empty(),
835            "invariant: body must be empty when stream is set"
836        );
837    }
838
839    /// Without the key, behaviour is exactly as today: buffered body, no stream.
840    #[tokio::test]
841    async fn test_upstream_buffers_when_not_permitted() {
842        let (port, _rx) = spawn_request_line_capture().await;
843        let ctx = ctx_with_query("/stream", vec![]);
844
845        let out = plugin_at(port).execute(ctx).await.unwrap();
846
847        assert!(out.context.response.stream.is_none());
848        assert_eq!(
849            out.context.response.body.as_ref(),
850            b"ok",
851            "buffered path must actually carry the upstream's body"
852        );
853    }
854
855    /// Sends response headers plus a first chunk immediately, then blocks
856    /// until `resume` is signaled before sending the final chunk and the
857    /// chunked terminator and closing — giving a test a window to observe
858    /// the balancer's in-flight count while the stream is still open, before
859    /// deciding when the body is allowed to finish.
860    async fn spawn_pausable_stream_server() -> (u16, tokio::sync::oneshot::Sender<()>) {
861        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
862        let port = listener.local_addr().unwrap().port();
863        let (resume_tx, resume_rx) = tokio::sync::oneshot::channel();
864        tokio::spawn(async move {
865            if let Ok((mut stream, _)) = listener.accept().await {
866                use tokio::io::{AsyncReadExt, AsyncWriteExt};
867                let mut buf = [0u8; 4096];
868                let _ = stream.read(&mut buf).await;
869                let _ = stream
870                    .write_all(
871                        b"HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\n\r\n5\r\nhello\r\n",
872                    )
873                    .await;
874                let _ = resume_rx.await;
875                let _ = stream.write_all(b"6\r\nworld!\r\n0\r\n\r\n").await;
876                let _ = stream.shutdown().await;
877            }
878        });
879        (port, resume_tx)
880    }
881
882    /// The balancer's in-flight guard must be bound to the streaming body's
883    /// own lifetime (via `body_holding`), not to this node's `execute` call.
884    /// Swapping that binding for a bare `drop(guard)` — the exact bug this
885    /// guards against — would release the count the instant `execute`
886    /// returns, long before the client has actually received the whole
887    /// body; the whole rest of the test suite stays green either way, which
888    /// is what makes this property worth testing directly rather than
889    /// trusting it stayed wired correctly.
890    #[tokio::test]
891    async fn test_in_flight_guard_released_only_when_stream_completes() {
892        use http_body_util::BodyExt;
893
894        let (port, resume_tx) = spawn_pausable_stream_server().await;
895        let plugin = plugin_at(port);
896        let mut ctx = ctx_with_query("/stream", vec![]);
897        ctx.message
898            .insert("__may_stream".to_string(), serde_json::json!(true));
899
900        let out = plugin.execute(ctx).await.unwrap();
901        let stream = out
902            .context
903            .response
904            .stream
905            .expect("expected a stream when __may_stream is set");
906
907        assert_eq!(
908            plugin.balancer.in_flight_count(0),
909            1,
910            "in-flight count must stay held while the stream is still open"
911        );
912
913        let (body, _guards) = stream.into_parts();
914        let _ = resume_tx.send(());
915        let collected = body.collect().await.unwrap().to_bytes();
916        assert_eq!(collected.as_ref(), b"helloworld!");
917
918        assert_eq!(
919            plugin.balancer.in_flight_count(0),
920            0,
921            "in-flight count must release once the stream body completes"
922        );
923    }
924}