dwara_core/dataplane/plugin_dispatch.rs
1//! Request-path plugin dispatch (DW-157, epic #274).
2//!
3//! This module is the wiring between the config's `plugins`/route
4//! `plugins` lists and the live request path. For every routed request
5//! whose route references plugins, it:
6//!
7//! 1. builds the per-request [`RequestPlugins`] — a fail-closed health
8//! gate over the generation's [`PluginLifecycle`] state, one
9//! per-request WASM instantiation through the lifecycle's runner
10//! (`wasm::runner::PluginRunner`), and the unified [`PluginChain`]
11//! (native filters + WASM entries in config order, dispatched
12//! through one [`WasmChainAdapter`]);
13//! 2. drives the chain at the four documented phase points
14//! (`request_headers` after route resolution before authn;
15//! `request_body` after authz/rate-limit before the action;
16//! `response_headers` after the response arrives before masking;
17//! `response_body` after masking before the operator transforms and
18//! compression);
19//! 3. runs `on_done` cleanup on EVERY exit path via `Drop`.
20//!
21//! ## Fast path discipline
22//!
23//! A route with an empty `plugins` list builds nothing:
24//! [`RequestPlugins::build`] returns `Ok(None)` before any allocation,
25//! and `handle_routed` skips every phase call — the no-plugin request
26//! path is byte-identical to the pre-DW-157 path.
27//!
28//! ## Body policy
29//!
30//! Header phases always run. Body phases require a buffered body: a
31//! route whose chain declares `request_body` (or `response_body`) has
32//! that body buffered up to the route's body cap
33//! ([`plugin_body_cap`] — `routes[].limits.max_body_bytes`, default
34//! [`PLUGIN_BODY_CAP_DEFAULT`]); an over-cap body answers 500
35//! `plugin_body_too_large` (fail-closed: a plugin that declared a body
36//! phase must see the body or the request does not proceed). Bodies on
37//! routes without body-phase plugins stay streamed — zero buffering,
38//! zero cost. The response side rides the same buffering masking
39//! already establishes when masking ran; otherwise it buffers the same
40//! way. One documented exception: a STREAMING response body
41//! (`text/event-stream`, or a body with no declared length — chunked
42//! or open-ended) or a CONTENT-ENCODED response body skips the
43//! `response_body` phase (logged once, `plugin_response_body_skipped`):
44//! buffering an endless stream would stall the route, an encoded body
45//! is opaque bytes to the plugin, and the gateway never decompresses on
46//! this path (its own compression runs later, after the phase).
47//!
48//! ## Failure semantics
49//!
50//! Everything fails closed, on the AFFECTED route only:
51//!
52//! - a Crashed/Disabled/not-loaded plugin, a native name missing from
53//! the registry, a native factory that errors while the chain is
54//! built, or a failed per-request instantiation answers 500
55//! `plugin_unavailable`;
56//! - a WASM trap (fuel/epoch/memory) or native filter error answers
57//! 500 `plugin_failed`;
58//! - an over-cap body answers 500 `plugin_body_too_large`;
59//! - a plugin's `send_http_response` is honored verbatim (status,
60//! headers, body) and the upstream is never dialed.
61//!
62//! Every failure increments `dwara_plugin_failures_total{name,reason}`
63//! and logs one server-side event naming the plugin; a plugin answering
64//! the request (short-circuit OR failure) sets the access log's
65//! `plugin_short_circuit` flag.
66//!
67//! ## Header conventions
68//!
69//! The proxy-wasm convention exposes HTTP framing as pseudo-headers.
70//! Matching the host/runner tests (tests/wasm_host.rs), the request
71//! header map carries `:method` and `:path` (path INCLUDING the query
72//! string) ahead of the real headers (`host` included verbatim); the
73//! response map carries `:status`. The write-back of ORDINARY headers
74//! applies ONLY the names the chain changed (`apply_header_changes`):
75//! headers no plugin touched keep their original bytes, non-UTF-8
76//! (obs-text) values included. Pseudo-headers never enter the ordinary
77//! map — the three target pseudo-headers a chain may rewrite are
78//! captured separately ([`pseudo_header_rewrite`]) and applied when
79//! the forwarded request is built:
80//!
81//! - `:path` — the FINAL upstream target, origin-form (`/...`),
82//! query string included (a written path without a query forwards
83//! without one). It composes with the route's own rewrite: the
84//! route rewrite applies first, the plugin's `:path` is the final
85//! say, and routes are never re-matched. Only the upstream sees the
86//! rewritten target — route matching, authn/authz, the cache key,
87//! anomaly scoring, and the access log all evaluated the ORIGINAL
88//! target and keep it.
89//! - `:method` — a valid method token; replaces the forwarded method.
90//! - `:authority` — overrides the forwarded `Host` header only (see
91//! [`PluginForwardHost`]); the dialed endpoint stays the balancer's
92//! pick.
93//!
94//! Only CHANGED values apply: writing back the value the map carried
95//! has zero effect, and REMOVING a pseudo-header is ignored (the
96//! request keeps its real method/path). An invalid changed value
97//! (empty, non-`/`-prefixed, or unparseable `:path`; a non-token
98//! `:method`; an unparseable `:authority`) fails closed: 500
99//! `plugin_failed`, metric reason `invalid_rewrite`, upstream never
100//! dialed.
101//!
102//! `:status` writes on the response map stay IGNORED (pipeline-owned)
103//! — a deliberate asymmetry with the request side, documented on
104//! [`RequestPlugins::response_headers_phase`].
105//!
106//! ## HTTP callouts: pause and resume (DW-167)
107//!
108//! A plugin phase callback may dispatch HTTP callouts
109//! (`proxy_http_call`) and return Pause. The phase method here — the
110//! ASYNC boundary — then drives the exchange, keeping the wasm runner
111//! synchronous:
112//!
113//! 1. the chain reports [`ChainOutcome::CalloutPending`] and stops at
114//! the paused entry;
115//! 2. [`RequestPlugins::resolve_callouts`] performs each pending
116//! exchange on `spawn_blocking` around the synchronous callout
117//! client (`wasm::callout`), records
118//! `dwara_plugin_callouts_total{name,outcome}` (ok, timeout,
119//! error), and delivers the response to the instance with a
120//! synchronous re-entry (`proxy_on_http_call_response`); the plugin
121//! may dispatch again (bounded rounds) or decide the request. A
122//! mid-delivery re-dispatch never abandons the rest of the drained
123//! queue: the un-performed remainder is re-enqueued ahead of the
124//! new dispatches (dispatch order preserved), so every dispatched
125//! callout is eventually performed or the loop guard trips;
126//! 3. on Continue the chain resumes from the entry AFTER the paused
127//! one with the plugin's post-callout payload.
128//!
129//! Guardrails (hard caps, no config surface): the plugin's timeout
130//! clamped to [1ms, 5s]; at most [`wasm::callout::MAX_CALLOUT_ROUNDS`]
131//! callout rounds per phase per request (exceeding fails closed, 500
132//! `plugin_failed`, metric reason `callout_loop`); callout response
133//! bodies capped at 4 MiB; redirects refused; the gateway SSRF egress
134//! filter applied at connect time against the resolved IPs.
135//!
136//! **Callout failure is a plugin failure** (a deliberate deviation
137//! from Envoy, which delivers an empty response callback on callout
138//! failure): a timed-out or refused callout answers 500
139//! `plugin_failed` (metric reason `callout_failed`) — the plugin
140//! cannot make a fail-open decision without receiving its decision
141//! input, and dwara's plugin posture is fail-closed end to end. Any
142//! COMPLETED status is delivered as data (non-2xx included); recipes
143//! wanting fail-open semantics scope them explicitly (short timeouts
144//! plus a non-callout fallback, or an extension trait instead of a
145//! plugin).
146
147use std::collections::HashMap;
148
149use bytes::Bytes;
150use http::uri::PathAndQuery;
151use http_body_util::{BodyExt as _, Full, LengthLimitError, Limited};
152use hyper::header::{HeaderMap, HeaderName, HeaderValue};
153use hyper::{Request, Response, StatusCode};
154
155use crate::config::ssrf::SsrfFilter;
156use crate::config::{Gateway, PluginConfig, PluginPhase, Route};
157use crate::dataplane::proxy::ProxyBody;
158use crate::observability::Observability;
159use crate::plugins::{ChainOutcome, LocalResponse, NativeRegistry, PluginChain};
160use crate::wasm::adapter::WasmChainAdapter;
161use crate::wasm::callout;
162use crate::wasm::lifecycle::{PluginHealth, PluginLifecycle};
163
164/// The default body cap for plugin body phases on routes without an
165/// explicit `limits.max_body_bytes` (DW-157): 1 MiB, the same bound the
166/// nano-service body cap uses. Buffering must be opt-in (the plugin
167/// declaring the phase) and size-capped (always) — this is the cap when
168/// the route configures none.
169pub const PLUGIN_BODY_CAP_DEFAULT: u64 = 1024 * 1024;
170
171/// The cap for plugin body phases (DW-157): the route's
172/// `limits.max_body_bytes` when configured, else
173/// [`PLUGIN_BODY_CAP_DEFAULT`]. The same bound governs the request and
174/// response sides (it is the only per-route body cap in the config
175/// surface). It applies even when the limits block is `dry_run` — it is
176/// a buffering bound for the plugin phase, not the 413 policy.
177pub fn plugin_body_cap(route: &Route) -> u64 {
178 route
179 .limits
180 .as_ref()
181 .and_then(|l| l.max_body_bytes)
182 .unwrap_or(PLUGIN_BODY_CAP_DEFAULT)
183}
184
185/// The fail-closed exit a plugin phase hands its caller: the response
186/// the proxy returns immediately (a plugin's local response or one of
187/// the 500 envelopes). Boxed — `Response<ProxyBody>` is large and the
188/// clippy `result_large_err` gate holds the hot-path `Result`s small.
189pub type PluginExit = Box<Response<ProxyBody>>;
190
191/// A chain's rewrite of the forwarded request target, captured at the
192/// `request_headers` phase ([`pseudo_header_rewrite`]) and applied
193/// when the upstream request is built: the phase runs early (before
194/// authn) but the rewrite shapes the UPSTREAM request only, so it
195/// rides the request extensions from the phase to the forward build
196/// (`proxy::proxy_request` removes it there — after the route's own
197/// rewrite and the query transforms, with no route re-match; see the
198/// module's "Header conventions" section). `Default` is the no-rewrite
199/// case (no pseudo-header changed); `is_noop` lets the caller skip
200/// the extension entirely on that fast path.
201#[derive(Clone, Debug, Default)]
202pub struct PluginTargetRewrite {
203 /// The validated `:path` write: origin-form, the WHOLE upstream
204 /// target including any query string.
205 pub path: Option<PathAndQuery>,
206 /// The validated `:method` write (a method token).
207 pub method: Option<hyper::Method>,
208 /// The validated `:authority` write, as the `Host` header value
209 /// the forwarded request will carry (see [`PluginForwardHost`]).
210 pub authority: Option<HeaderValue>,
211}
212
213impl PluginTargetRewrite {
214 /// Whether every field is unset — no pseudo-header was changed
215 /// (the common case; the caller skips the extension ride).
216 pub fn is_noop(&self) -> bool {
217 self.path.is_none() && self.method.is_none() && self.authority.is_none()
218 }
219}
220
221/// The `Host` override a plugin's `:authority` write produced. Rides
222/// the request extensions from the forward build to the upstream
223/// dispatch (`upstream::UpstreamHandle` owns the forwarded `Host`):
224/// the header the plugin named replaces the picked endpoint's
225/// authority there, and ONLY there — the dial target and the URI
226/// authority stay the balancer's pick (a plugin shapes the `Host`
227/// header, never which origin the gateway dials).
228#[derive(Clone, Debug)]
229pub struct PluginForwardHost(pub HeaderValue);
230
231/// The per-request plugin execution state (DW-157): the unified chain
232/// plus the fail-closed helpers that drive it. Constructed by
233/// [`RequestPlugins::build`] only on routes that reference plugins;
234/// `Drop` runs the `on_done` cleanup callback on every exit path
235/// (early short-circuits, failures, and the natural tail alike).
236pub struct RequestPlugins {
237 chain: PluginChain<WasmChainAdapter>,
238 /// The first plugin on the route declaring `request_headers` —
239 /// the deterministic attribution for a fail-closed invalid target
240 /// rewrite (the merged header map has no per-plugin author; the
241 /// same first-declarer rule the body phases attribute by).
242 request_headers_plugin: Option<String>,
243 /// The first plugin on the route declaring `request_body` — the
244 /// deterministic attribution for a request body the gateway could
245 /// not buffer for the phase (over-cap).
246 request_body_plugin: Option<String>,
247 /// The first plugin on the route declaring `response_body` — the
248 /// attribution for the response side of the same rule.
249 response_body_plugin: Option<String>,
250 /// The generation's SSRF egress filter, applied at callout connect
251 /// time (DW-167) — the same posture as webhook deliveries and
252 /// registry fetches.
253 ssrf: SsrfFilter,
254}
255
256impl Drop for RequestPlugins {
257 fn drop(&mut self) {
258 self.chain.on_done();
259 }
260}
261
262impl RequestPlugins {
263 /// Build the per-request chain for `route`. `Ok(None)` — with zero
264 /// allocations — when the route references no plugins (the fast
265 /// path). `Err(response)` is the fail-closed 500 a caller returns
266 /// immediately: a referenced plugin is Crashed/Disabled/not loaded,
267 /// a native name is missing from the registry, a native factory
268 /// errors while the chain is built, or per-request WASM
269 /// instantiation failed or did not cover every WASM plugin.
270 pub fn build(
271 lifecycle: &PluginLifecycle,
272 configs: &HashMap<String, PluginConfig>,
273 registry: &NativeRegistry,
274 route: &Route,
275 obs: &Observability,
276 rid: &str,
277 ssrf: SsrfFilter,
278 ) -> Result<Option<Self>, PluginExit> {
279 if route.plugins.is_empty() {
280 return Ok(None);
281 }
282 let mut request_headers_plugin = None;
283 let mut request_body_plugin = None;
284 let mut response_body_plugin = None;
285 // Health gate: every referenced plugin must be usable BEFORE the
286 // request proceeds (fail-closed). Validation already rejected
287 // names absent from the top-level list; a miss here is a
288 // generation tear, and it still fails closed rather than
289 // silently skipping the plugin.
290 for name in &route.plugins {
291 let Some(config) = configs.get(name) else {
292 return Err(Box::new(unavailable(obs, name, "not_loaded", route, rid)));
293 };
294 if config.phases.contains(&PluginPhase::RequestHeaders)
295 && request_headers_plugin.is_none()
296 {
297 request_headers_plugin = Some(name.clone());
298 }
299 if config.phases.contains(&PluginPhase::RequestBody) && request_body_plugin.is_none() {
300 request_body_plugin = Some(name.clone());
301 }
302 if config.phases.contains(&PluginPhase::ResponseBody) && response_body_plugin.is_none()
303 {
304 response_body_plugin = Some(name.clone());
305 }
306 if let Some(native) = &config.native {
307 if !registry.contains(native) {
308 return Err(Box::new(unavailable(
309 obs,
310 name,
311 "not_registered",
312 route,
313 rid,
314 )));
315 }
316 continue;
317 }
318 // A WASM plugin (`wasm:` or the registry `source:` variant,
319 // resolved to a verified local artifact at publish — DW-165).
320 // It must have entered the lifecycle this generation: a miss
321 // is a generation tear and fails closed rather than skipping.
322 let Some(loaded) = lifecycle.get_plugin(name) else {
323 return Err(Box::new(unavailable(obs, name, "not_loaded", route, rid)));
324 };
325 match &loaded.health {
326 PluginHealth::Healthy => {}
327 PluginHealth::Crashed { error, .. } => {
328 tracing::warn!(
329 code = "plugin_unavailable",
330 request_id = %rid,
331 route = %route.name,
332 plugin = %name,
333 reason = %error,
334 "route references a crashed plugin; failing closed"
335 );
336 obs.record_plugin_failure(name, "crashed");
337 return Err(Box::new(unavailable_response(rid)));
338 }
339 PluginHealth::Disabled { reason } => {
340 tracing::warn!(
341 code = "plugin_unavailable",
342 request_id = %rid,
343 route = %route.name,
344 plugin = %name,
345 reason = %reason,
346 "route references a disabled plugin; failing closed"
347 );
348 obs.record_plugin_failure(name, "disabled");
349 return Err(Box::new(unavailable_response(rid)));
350 }
351 }
352 }
353 // Per-request instantiation through the compiled modules. All
354 // WASM plugins on the route share ONE adapter (the chain
355 // dispatches per name; see WasmChainAdapter). A native-only
356 // chain gets the EMPTY instance set (every per-name dispatch
357 // passes through — native filters dispatch through the chain's
358 // own entries). The runner can be absent only before the first
359 // successful load — every plugin then reads as not loaded
360 // above; reaching here without one is a generation tear and
361 // fails closed the same way.
362 let has_wasm = route.plugins.iter().any(|n| {
363 configs
364 .get(n)
365 .is_some_and(|c| c.wasm.is_some() || c.source.is_some())
366 });
367 let instances = if has_wasm {
368 let Some(runner) = lifecycle.runner() else {
369 return Err(Box::new(unavailable(
370 obs,
371 &route.plugins[0],
372 "not_loaded",
373 route,
374 rid,
375 )));
376 };
377 match runner.instantiate(&route.plugins) {
378 Some(instances) => instances,
379 None => {
380 return Err(Box::new(unavailable(
381 obs,
382 &route.plugins[0],
383 "instantiate_failed",
384 route,
385 rid,
386 )));
387 }
388 }
389 } else {
390 crate::wasm::runner::PluginInstances::empty()
391 };
392 // Coverage: every WASM plugin on the route must hold a
393 // per-request instance (a missing instance would silently skip
394 // a configured plugin). Native-only names never enter the
395 // runner and are exempt.
396 for name in &route.plugins {
397 let is_wasm = configs
398 .get(name)
399 .is_some_and(|c| (c.wasm.is_some() || c.source.is_some()) && c.native.is_none());
400 if is_wasm && !instances.contains(name) {
401 tracing::warn!(
402 code = "plugin_unavailable",
403 request_id = %rid,
404 route = %route.name,
405 plugin = %name,
406 reason = "per-request instantiation failed",
407 "route references a plugin that could not be instantiated"
408 );
409 obs.record_plugin_failure(name, "instantiate_failed");
410 return Err(Box::new(unavailable_response(rid)));
411 }
412 }
413 let (chain, create_failures) = PluginChain::new(
414 &route.plugins,
415 configs,
416 registry,
417 WasmChainAdapter::new(instances),
418 );
419 // Fail-closed native construction (DW-157): the health gate
420 // admitted the entry (the name IS registered), but its factory
421 // errored — the configured filter never runs, so the request
422 // must not proceed. Never skip a create failure the gate
423 // admitted.
424 if let Some(failure) = create_failures.first() {
425 tracing::warn!(
426 code = "plugin_unavailable",
427 request_id = %rid,
428 route = %route.name,
429 plugin = %failure.plugin,
430 reason = %failure.message,
431 "native filter construction failed; failing closed"
432 );
433 obs.record_plugin_failure(&failure.plugin, "instantiate_failed");
434 return Err(Box::new(unavailable_response(rid)));
435 }
436 Ok(Some(Self {
437 chain,
438 request_headers_plugin,
439 request_body_plugin,
440 response_body_plugin,
441 ssrf,
442 }))
443 }
444
445 /// Whether the chain has any plugin declaring `request_body`
446 /// (buffers the request body when true — see the module docs).
447 pub fn needs_request_body(&self) -> bool {
448 self.chain.has_phase(PluginPhase::RequestBody)
449 }
450
451 /// Whether the chain has any plugin declaring `response_body`
452 /// (buffers the response body when true — see the module docs).
453 pub fn needs_response_body(&self) -> bool {
454 self.chain.has_phase(PluginPhase::ResponseBody)
455 }
456
457 /// Run the `request_headers` phase: after route resolution, before
458 /// authn (the documented contract — authn sees plugin-modified
459 /// headers). `path_and_query` is the full request target (path
460 /// including any query string). On `Ok` the (possibly rewritten)
461 /// header map is in place and the returned [`PluginTargetRewrite`]
462 /// carries the chain's validated `:path`/`:method`/`:authority`
463 /// writes (see the module's "Header conventions" section) for the
464 /// caller to apply at the forward build; on `Err` the caller
465 /// returns the response immediately. Async because a plugin may
466 /// dispatch HTTP callouts here (DW-167): the phase pauses, the
467 /// callouts are performed off-thread, and the chain resumes with
468 /// the plugin's post-callout output.
469 pub async fn request_headers_phase(
470 &mut self,
471 method: &hyper::Method,
472 path_and_query: &str,
473 headers: &mut HeaderMap,
474 obs: &Observability,
475 rid: &str,
476 route: &str,
477 ) -> Result<PluginTargetRewrite, PluginExit> {
478 let mut map = Vec::with_capacity(headers.len() + 2);
479 map.push((":method".to_string(), method.as_str().to_string()));
480 map.push((":path".to_string(), path_and_query.to_string()));
481 for (k, v) in headers.iter() {
482 map.push((k.as_str().to_string(), v.to_str().unwrap_or("").to_string()));
483 }
484 // The write-back diffs against this snapshot of the input (the
485 // chain consumes `map`); see `apply_header_changes`.
486 let input = map.clone();
487 let mut outcome = self.chain.on_request_headers(map);
488 let mut callout_rounds: u32 = 0;
489 loop {
490 match outcome {
491 (ChainOutcome::Continue, out) => {
492 apply_header_changes(headers, &input, &out);
493 let rewrite = pseudo_header_rewrite(
494 &input,
495 &out,
496 obs,
497 rid,
498 route,
499 self.request_headers_plugin.as_deref(),
500 )?;
501 return Ok(rewrite);
502 }
503 (ChainOutcome::LocalResponse(resp), _) => {
504 plugin_decision(rid, route);
505 return Err(Box::new(local_response(resp, rid)));
506 }
507 (ChainOutcome::Error { plugin, message }, _) => {
508 return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
509 }
510 (ChainOutcome::CalloutPending, _) => {
511 if let Some(resp) = self
512 .resolve_callouts(obs, rid, route, &mut callout_rounds)
513 .await?
514 {
515 plugin_decision(rid, route);
516 return Err(Box::new(local_response(resp, rid)));
517 }
518 let headers = self.chain.wasm().paused_request_headers();
519 self.chain.wasm_mut().clear_pause();
520 outcome = self.chain.resume_request_headers(headers);
521 }
522 }
523 }
524 }
525
526 /// Run the `request_body` phase: buffer the request body (capped —
527 /// over-cap answers 500 `plugin_body_too_large`, fail-closed), let
528 /// the chain rewrite it, and hand the action a fully-buffered
529 /// `Full<Bytes>` body with a rewritten `Content-Length`. Runs after
530 /// authz/rate-limit, before the route action (and before request
531 /// validation, which then sees the post-plugin bytes).
532 pub async fn request_body_phase<B>(
533 &mut self,
534 req: Request<B>,
535 cap: u64,
536 obs: &Observability,
537 rid: &str,
538 route: &str,
539 ) -> Result<Request<Full<Bytes>>, PluginExit>
540 where
541 B: hyper::body::Body<Data = Bytes> + Send + 'static,
542 B::Error: Into<Box<dyn std::error::Error + Send + Sync>>,
543 {
544 let (mut parts, body) = req.into_parts();
545 let bytes = match Limited::new(body, cap as usize).collect().await {
546 Ok(collected) => collected.to_bytes(),
547 Err(err) => {
548 if err.downcast_ref::<LengthLimitError>().is_some() {
549 return Err(Box::new(body_too_large(
550 obs,
551 rid,
552 route,
553 cap,
554 self.request_body_plugin.as_deref(),
555 )));
556 }
557 tracing::warn!(
558 code = "plugin_body_read_failed",
559 request_id = %rid,
560 route = %route,
561 error = %err,
562 "request body could not be buffered for the plugin phase"
563 );
564 return Err(Box::new(simple(
565 StatusCode::BAD_REQUEST,
566 "plugin_body_read_failed",
567 "request body could not be read",
568 rid,
569 )));
570 }
571 };
572 let mut outcome = self.chain.on_request_body(bytes.to_vec());
573 let mut callout_rounds: u32 = 0;
574 loop {
575 match outcome {
576 (ChainOutcome::Continue, out) => {
577 let bytes = Bytes::from(out);
578 set_content_length(&mut parts.headers, bytes.len());
579 return Ok(Request::from_parts(parts, Full::new(bytes)));
580 }
581 (ChainOutcome::LocalResponse(resp), _) => {
582 plugin_decision(rid, route);
583 return Err(Box::new(local_response(resp, rid)));
584 }
585 (ChainOutcome::Error { plugin, message }, _) => {
586 return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
587 }
588 (ChainOutcome::CalloutPending, _) => {
589 if let Some(resp) = self
590 .resolve_callouts(obs, rid, route, &mut callout_rounds)
591 .await?
592 {
593 plugin_decision(rid, route);
594 return Err(Box::new(local_response(resp, rid)));
595 }
596 let body = self.chain.wasm().paused_request_body();
597 self.chain.wasm_mut().clear_pause();
598 outcome = self.chain.resume_request_body(body);
599 }
600 }
601 }
602 }
603
604 /// Run the `response_headers` phase: after the response arrives
605 /// (any action: proxy, mock, respond, redirect alike — plugins are
606 /// route-level), before masking. `:status` rides the map for
607 /// visibility; plugin writes to it are IGNORED — a deliberate
608 /// asymmetry with the request-side target rewrite: by the time
609 /// this phase runs the status is bound to the response's framing
610 /// (204/304/101 carry no body, and `Content-Length` already
611 /// matches the upstream bytes a later phase may rewrite), so a
612 /// mid-pipeline flip would desynchronize both. A plugin that
613 /// wants to DECIDE the answer has `send_http_response` at the
614 /// request phases. Async for the callout pause/resume path
615 /// (DW-167), like every phase method.
616 pub async fn response_headers_phase(
617 &mut self,
618 status: StatusCode,
619 headers: &mut HeaderMap,
620 obs: &Observability,
621 rid: &str,
622 route: &str,
623 ) -> Result<(), PluginExit> {
624 let mut map = Vec::with_capacity(headers.len() + 1);
625 map.push((":status".to_string(), status.as_u16().to_string()));
626 for (k, v) in headers.iter() {
627 map.push((k.as_str().to_string(), v.to_str().unwrap_or("").to_string()));
628 }
629 // The write-back diffs against this snapshot of the input (the
630 // chain consumes `map`); see `apply_header_changes`.
631 let input = map.clone();
632 let mut outcome = self.chain.on_response_headers(map);
633 let mut callout_rounds: u32 = 0;
634 loop {
635 match outcome {
636 (ChainOutcome::Continue, out) => {
637 apply_header_changes(headers, &input, &out);
638 return Ok(());
639 }
640 (ChainOutcome::LocalResponse(resp), _) => {
641 plugin_decision(rid, route);
642 return Err(Box::new(local_response(resp, rid)));
643 }
644 (ChainOutcome::Error { plugin, message }, _) => {
645 return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
646 }
647 (ChainOutcome::CalloutPending, _) => {
648 if let Some(resp) = self
649 .resolve_callouts(obs, rid, route, &mut callout_rounds)
650 .await?
651 {
652 plugin_decision(rid, route);
653 return Err(Box::new(local_response(resp, rid)));
654 }
655 let headers = self.chain.wasm().paused_response_headers();
656 self.chain.wasm_mut().clear_pause();
657 outcome = self.chain.resume_response_headers(headers);
658 }
659 }
660 }
661 }
662
663 /// Run the `response_body` phase: after masking, before the
664 /// operator transforms and compression. Bodiless statuses
665 /// (informational, 101, 204, 304) have nothing to inspect and pass
666 /// through untouched, exactly like masking; STREAMING bodies
667 /// (`text/event-stream`, or a body of unknown length — chunked or
668 /// otherwise un-framed) and CONTENT-ENCODED bodies are skipped,
669 /// documented and logged once per response: buffering an endless
670 /// SSE stream would stall the route, and an encoded body is opaque
671 /// bytes to the plugin (dwara never decompresses on this path —
672 /// compression is applied downstream, after this phase). Every
673 /// other response on a chain with a `response_body` plugin is
674 /// buffered up to `cap` (over-cap answers 500
675 /// `plugin_body_too_large`, fail-closed), the chain may rewrite the
676 /// bytes, and the response is rebuilt with a `Full` body and a
677 /// rewritten `Content-Length`. When masking already buffered the
678 /// body (its fail-closed path) this rides the same buffered bytes.
679 pub async fn response_body_phase(
680 &mut self,
681 resp: Response<ProxyBody>,
682 cap: u64,
683 obs: &Observability,
684 rid: &str,
685 route: &str,
686 ) -> Result<Response<ProxyBody>, PluginExit> {
687 let status = resp.status();
688 if status.is_informational()
689 || status == StatusCode::SWITCHING_PROTOCOLS
690 || status == StatusCode::NO_CONTENT
691 || status == StatusCode::NOT_MODIFIED
692 {
693 return Ok(resp);
694 }
695 if let Some(reason) = plugin_body_skip_reason(&resp) {
696 tracing::debug!(
697 code = "plugin_response_body_skipped",
698 request_id = %rid,
699 route = %route,
700 reason = reason,
701 "streaming or content-encoded response; the response_body plugin phase is skipped"
702 );
703 return Ok(resp);
704 }
705 let (mut parts, body) = resp.into_parts();
706 let bytes = match Limited::new(body, cap as usize).collect().await {
707 Ok(collected) => collected.to_bytes(),
708 Err(err) => {
709 if err.downcast_ref::<LengthLimitError>().is_some() {
710 return Err(Box::new(body_too_large(
711 obs,
712 rid,
713 route,
714 cap,
715 self.response_body_plugin.as_deref(),
716 )));
717 }
718 // The response stream died mid-body before the phase
719 // could run: attribute to the first response-body
720 // plugin (the plugin itself never executed — the
721 // distinct `response_stream_ended` reason keeps that
722 // clear in the metric), fail closed.
723 let name = self.response_body_plugin.as_deref().unwrap_or(route);
724 tracing::warn!(
725 code = "plugin_failed",
726 request_id = %rid,
727 route = %route,
728 plugin = name,
729 error = %err,
730 "response body could not be buffered for the plugin phase"
731 );
732 obs.record_plugin_failure(name, "response_stream_ended");
733 return Err(Box::new(plugin_failed_response(rid)));
734 }
735 };
736 let mut outcome = self.chain.on_response_body(bytes.to_vec());
737 let mut callout_rounds: u32 = 0;
738 loop {
739 match outcome {
740 (ChainOutcome::Continue, out) => {
741 let bytes = Bytes::from(out);
742 set_content_length(&mut parts.headers, bytes.len());
743 return Ok(Response::from_parts(
744 parts,
745 ProxyBody::Full(Full::new(bytes)),
746 ));
747 }
748 (ChainOutcome::LocalResponse(resp), _) => {
749 plugin_decision(rid, route);
750 return Err(Box::new(local_response(resp, rid)));
751 }
752 (ChainOutcome::Error { plugin, message }, _) => {
753 return Err(Box::new(plugin_error(obs, rid, route, &plugin, &message)));
754 }
755 (ChainOutcome::CalloutPending, _) => {
756 if let Some(resp) = self
757 .resolve_callouts(obs, rid, route, &mut callout_rounds)
758 .await?
759 {
760 plugin_decision(rid, route);
761 return Err(Box::new(local_response(resp, rid)));
762 }
763 let body = self.chain.wasm().paused_response_body();
764 self.chain.wasm_mut().clear_pause();
765 outcome = self.chain.resume_response_body(body);
766 }
767 }
768 }
769 }
770
771 /// The callout pause/resume driver (DW-167): perform every pending
772 /// exchange of the paused plugin off-thread, record the outcome
773 /// metric, deliver the response with a synchronous wasmtime
774 /// re-entry, and repeat while the plugin keeps dispatching —
775 /// `rounds` counts this PHASE's exchanges (the caller owns the
776 /// counter, so the cap spans every plugin and pause in the phase).
777 /// `Ok(None)` = resolved; the caller reads the resumed payload and
778 /// resumes the chain. `Ok(Some(resp))` = the plugin decided the
779 /// request from a callout callback (`send_http_response`); the
780 /// caller answers with it. `Err` = a fail-closed exit (callout
781 /// failure, loop guard, or plugin trap).
782 async fn resolve_callouts(
783 &mut self,
784 obs: &Observability,
785 rid: &str,
786 route: &str,
787 rounds: &mut u32,
788 ) -> Result<Option<LocalResponse>, PluginExit> {
789 loop {
790 // Drain the paused plugin's queue (FIFO; usually one). An
791 // empty drain means the paused plugin finished (its last
792 // delivery resumed Continue and the payload waits for the
793 // caller) — or nothing paused at all.
794 let Some((name, requests)) = self.chain.wasm_mut().pending_callouts() else {
795 break;
796 };
797 if requests.is_empty() {
798 break;
799 }
800 let mut queue = requests.into_iter();
801 while let Some(request) = queue.next() {
802 *rounds += 1;
803 if *rounds > callout::MAX_CALLOUT_ROUNDS {
804 return Err(Box::new(self.callout_loop_guard(obs, rid, route)));
805 }
806 let token = request.token;
807 let ssrf = self.ssrf.clone();
808 let result = match tokio::task::spawn_blocking(move || {
809 callout::perform_callout(&request, &ssrf)
810 })
811 .await
812 {
813 Ok(result) => result,
814 // The blocking task itself died (panic): same
815 // fail-closed error path as a transport failure.
816 Err(join) => Err(callout::CalloutError::Failed(format!(
817 "callout task failed: {join}"
818 ))),
819 };
820 match result {
821 Ok(resp) => {
822 obs.record_plugin_callout(&name, "ok");
823 match self.chain.wasm_mut().deliver_callout(token, &resp) {
824 // Continue: deliver any remaining queued
825 // responses; the phase resumes when the
826 // queue drains.
827 ChainOutcome::Continue => {}
828 // The callback dispatched again mid-queue:
829 // the un-performed remainder must NOT be
830 // abandoned (its callback would never fire
831 // while the phase resumed as if it had) —
832 // re-enqueue it ahead of the new queue and
833 // let the next iteration drain both.
834 ChainOutcome::CalloutPending => {
835 let remainder: Vec<callout::CalloutRequest> = queue.collect();
836 if !remainder.is_empty() {
837 self.chain.wasm_mut().requeue_callouts(remainder);
838 }
839 break;
840 }
841 ChainOutcome::LocalResponse(resp) => return Ok(Some(resp)),
842 ChainOutcome::Error { plugin, message } => {
843 return Err(Box::new(plugin_error(
844 obs, rid, route, &plugin, &message,
845 )));
846 }
847 }
848 }
849 Err(e) => {
850 // Callout failure = plugin failure (see the
851 // module docs for the deliberate deviation
852 // from Envoy's empty-callback delivery).
853 obs.record_plugin_callout(&name, e.outcome());
854 tracing::warn!(
855 code = "plugin_failed",
856 request_id = %rid,
857 route = %route,
858 plugin = %name,
859 outcome = e.outcome(),
860 reason = %e,
861 "plugin callout failed; failing closed"
862 );
863 obs.record_plugin_failure(&name, "callout_failed");
864 return Err(Box::new(plugin_failed_response(rid)));
865 }
866 }
867 }
868 }
869 Ok(None)
870 }
871
872 /// The fail-closed 500 for the callout loop guard: a plugin that
873 /// never stops dispatching callouts (every response callback
874 /// dispatches another) is stopped after
875 /// [`callout::MAX_CALLOUT_ROUNDS`] rounds — the metrics record
876 /// both the loop_guard callout outcome and the `callout_loop`
877 /// failure reason, attributed to the paused plugin. Missing
878 /// attribution uses the fixed "unknown" sentinel (a route NAME is
879 /// not a plugin name; the label space stays config-bounded
880 /// either way).
881 fn callout_loop_guard(
882 &mut self,
883 obs: &Observability,
884 rid: &str,
885 route: &str,
886 ) -> Response<ProxyBody> {
887 let name = self
888 .chain
889 .wasm()
890 .paused_plugin()
891 .unwrap_or("unknown")
892 .to_string();
893 tracing::warn!(
894 code = "plugin_failed",
895 request_id = %rid,
896 route = %route,
897 plugin = %name,
898 rounds = callout::MAX_CALLOUT_ROUNDS,
899 "plugin exceeded the per-phase callout round cap; failing closed"
900 );
901 obs.record_plugin_callout(&name, "loop_guard");
902 obs.record_plugin_failure(&name, "callout_loop");
903 plugin_failed_response(rid)
904 }
905}
906
907// --- Plugin status surface (DW-158) ---------------------------------------
908
909/// A plugin's observable lifecycle state for the status surface (`GET
910/// /plugins`, `dwara-cli status`, `dwara_plugin_total{state}`). The
911/// three lifecycle states come from [`PluginHealth`]; the two extra
912/// states cover plugins that are declared in the config but unusable
913/// BEFORE the lifecycle can judge them (the same conditions the
914/// request-path gate fails closed on, so the status surface never
915/// shows a plugin as serving when its routes answer 500).
916#[derive(Clone, Debug, PartialEq, Eq)]
917pub enum PluginStatusState {
918 /// Loaded and serving: a healthy WASM plugin or a registered
919 /// native filter.
920 Healthy,
921 /// The .wasm could not be read or compiled (lifecycle judgment);
922 /// routes referencing it fail closed.
923 Crashed { error: String, crash_count: u32 },
924 /// Disabled (manually or by a circuit breaker); routes referencing
925 /// it fail closed.
926 Disabled { reason: String },
927 /// Declared but not present in the running plugin runtime: a
928 /// registry `source:` artifact that has not been through a
929 /// resolution+load cycle yet, or a generation tear after a failed
930 /// engine rebuild.
931 NotLoaded,
932 /// A native filter whose registered name has no factory in the
933 /// registry; routes referencing it fail closed.
934 NotRegistered,
935}
936
937impl PluginStatusState {
938 /// The closed label vocabulary shared by `GET /plugins`, the CLI
939 /// status section, and `dwara_plugin_total{state}` — one mapping,
940 /// three surfaces, no drift.
941 pub fn label(&self) -> &'static str {
942 match self {
943 PluginStatusState::Healthy => "healthy",
944 PluginStatusState::Crashed { .. } => "crashed",
945 PluginStatusState::Disabled { .. } => "disabled",
946 PluginStatusState::NotLoaded => "not_loaded",
947 PluginStatusState::NotRegistered => "not_registered",
948 }
949 }
950}
951
952/// One plugin's status entry (DW-158): what the admin endpoint, the
953/// CLI, and the per-state gauge are all built from.
954#[derive(Clone, Debug)]
955pub struct PluginStatusEntry {
956 /// The config-declared plugin name.
957 pub name: String,
958 /// `"wasm"` (local `.wasm` path), `"registry"` (remote source), or
959 /// `"native"` (compiled-in filter).
960 pub kind: &'static str,
961 /// Where the plugin comes from: the `.wasm` path, the registry
962 /// URL, or the registered native filter name.
963 pub source: String,
964 /// SHA-256 digest of the loaded `.wasm` artifact (hot-swap
965 /// change-detection key). Empty when there is no digest to report:
966 /// native plugins (no artifact), registry plugins before their
967 /// artifact is resolved, or a `.wasm` that could not be read.
968 pub sha256: String,
969 /// The plugin's lifecycle state.
970 pub state: PluginStatusState,
971 /// Effective resource limits — configured value or the documented
972 /// default — as `(fuel, memory_mb, timeout_ms)`. `None` for native
973 /// plugins (in-process; the config schema documents `limits` as
974 /// wasm-only).
975 pub limits: Option<(u64, usize, u64)>,
976 /// The phases the plugin declared (config order).
977 pub phases: Vec<PluginPhase>,
978 /// Route names whose `plugins` list references this plugin (config
979 /// order) — the blast radius of a state change.
980 pub referenced_by: Vec<String>,
981}
982
983/// Enumerate the generation's declared plugins as status entries
984/// (DW-158), in config order. Health and digest come from the live
985/// [`PluginLifecycle`] (WASM local and registry-resolved alike,
986/// DW-165) or the [`NativeRegistry`] (native names). The dataplane
987/// serves this through `DataPlane::plugin_statuses` (the admin `GET
988/// /plugins` handler) and folds it into `dwara_plugin_total{state}`
989/// at publish time — the same enumeration, so the endpoint, the CLI,
990/// and the metric can never disagree.
991pub fn plugin_statuses(
992 lifecycle: &PluginLifecycle,
993 registry: &NativeRegistry,
994 gateway: &Gateway,
995) -> Vec<PluginStatusEntry> {
996 gateway
997 .plugins
998 .iter()
999 .map(|config| {
1000 let referenced_by: Vec<String> = gateway
1001 .routes
1002 .iter()
1003 .filter(|r| r.plugins.iter().any(|n| n == &config.name))
1004 .map(|r| r.name.clone())
1005 .collect();
1006 let (kind, source, sha256, state, limits) = if let Some(native) = &config.native {
1007 // DW-119: a compiled-in filter. The registry lookup IS
1008 // its health: an unregistered name fails closed at
1009 // request time exactly like a crashed plugin.
1010 let state = if registry.contains(native) {
1011 PluginStatusState::Healthy
1012 } else {
1013 PluginStatusState::NotRegistered
1014 };
1015 ("native", native.clone(), String::new(), state, None)
1016 } else {
1017 // A WASM plugin from either local origin: a `wasm:`
1018 // path or a registry `source:` (SCALE-12/DW-165)
1019 // resolved to a verified local artifact. Health and
1020 // digest come from the lifecycle, which loads/compiles
1021 // with every generation (checksum-keyed); a `source:`
1022 // that failed resolution reads Crashed with the
1023 // step-named error, its routes fail-closed.
1024 let (kind, source) = if let Some(src) = &config.source {
1025 ("registry", src.url.clone())
1026 } else {
1027 ("wasm", config.wasm.clone().unwrap_or_default())
1028 };
1029 let loaded = lifecycle.get_plugin(&config.name);
1030 let (sha256, state) = match &loaded {
1031 None => (String::new(), PluginStatusState::NotLoaded),
1032 Some(lp) => {
1033 let state = match &lp.health {
1034 PluginHealth::Healthy => PluginStatusState::Healthy,
1035 PluginHealth::Crashed { error, crash_count } => {
1036 PluginStatusState::Crashed {
1037 error: error.clone(),
1038 crash_count: *crash_count,
1039 }
1040 }
1041 PluginHealth::Disabled { reason } => PluginStatusState::Disabled {
1042 reason: reason.clone(),
1043 },
1044 };
1045 (lp.checksum.clone(), state)
1046 }
1047 };
1048 (kind, source, sha256, state, Some(effective_limits(config)))
1049 };
1050 PluginStatusEntry {
1051 name: config.name.clone(),
1052 kind,
1053 source,
1054 sha256,
1055 state,
1056 limits,
1057 phases: config.phases.clone(),
1058 referenced_by,
1059 }
1060 })
1061 .collect()
1062}
1063
1064/// A plugin's effective limits: the configured value where set, else
1065/// the documented defaults (the same defaults the runner instantiates
1066/// with, single-sourced from [`crate::wasm::PluginLimits::default`]).
1067fn effective_limits(config: &PluginConfig) -> (u64, usize, u64) {
1068 let defaults = crate::wasm::PluginLimits::default();
1069 let limits = config.limits.as_ref();
1070 (
1071 limits.and_then(|l| l.fuel).unwrap_or(defaults.fuel),
1072 limits
1073 .and_then(|l| l.memory_mb)
1074 .unwrap_or(defaults.memory_mb),
1075 limits
1076 .and_then(|l| l.timeout_ms)
1077 .unwrap_or(defaults.timeout_ms),
1078 )
1079}
1080
1081/// Group a header list by name (first-seen order), preserving each
1082/// name's value sequence so multiplicity is compared exactly.
1083/// Pseudo-headers (leading `:`) are excluded — the ordinary header map
1084/// never carries them; the request-side target pseudo-headers are
1085/// captured separately by [`pseudo_header_rewrite`].
1086fn group_header_list(list: &[(String, String)]) -> Vec<(String, Vec<&str>)> {
1087 let mut grouped: Vec<(String, Vec<&str>)> = Vec::new();
1088 for (name, value) in list {
1089 if name.starts_with(':') {
1090 continue;
1091 }
1092 match grouped.iter_mut().find(|(n, _)| n == name) {
1093 Some((_, values)) => values.push(value.as_str()),
1094 None => grouped.push((name.clone(), vec![value.as_str()])),
1095 }
1096 }
1097 grouped
1098}
1099
1100/// The pseudo-headers whose CHANGED values rewrite the forwarded
1101/// request (see the module docs): `:path` (the whole upstream target,
1102/// query string included), `:method`, and `:authority` (the forwarded
1103/// `Host`). Any other pseudo-header name stays ignored.
1104const TARGET_PSEUDO_HEADERS: [&str; 3] = [":path", ":method", ":authority"];
1105
1106/// The LAST value a header list carries for `name` (case-insensitive
1107/// — the host's replace/add hostcalls both land the newest write at or
1108/// after the previous one), or `None` when the name is absent.
1109fn last_header_value<'a>(list: &'a [(String, String)], name: &str) -> Option<&'a str> {
1110 list.iter()
1111 .rev()
1112 .find(|(k, _)| k.eq_ignore_ascii_case(name))
1113 .map(|(_, v)| v.as_str())
1114}
1115
1116/// Extract the chain's pseudo-header target rewrite from the
1117/// `request_headers` output and validate it (see the module's "Header
1118/// conventions" section). Only names whose value the chain CHANGED
1119/// participate — writing back the value the map carried is the
1120/// documented no-op, and a REMOVED pseudo-header is ignored (the
1121/// request keeps its real method/path). An invalid changed value fails
1122/// closed (500 `plugin_failed`, metric reason `invalid_rewrite`),
1123/// attributed to the first plugin declaring the phase (`plugin`; the
1124/// merged chain output has no per-plugin author — the same
1125/// deterministic first-declarer attribution the body phases use).
1126/// The invalid value itself is never logged: a `:path` carries the
1127/// query string, which logs must not see.
1128fn pseudo_header_rewrite(
1129 input: &[(String, String)],
1130 output: &[(String, String)],
1131 obs: &Observability,
1132 rid: &str,
1133 route: &str,
1134 plugin: Option<&str>,
1135) -> Result<PluginTargetRewrite, PluginExit> {
1136 let mut rewrite = PluginTargetRewrite::default();
1137 for name in TARGET_PSEUDO_HEADERS {
1138 // Absent from the output (never written, or removed): no
1139 // rewrite from this name.
1140 let Some(new) = last_header_value(output, name) else {
1141 continue;
1142 };
1143 // Identical to the input value: zero effect.
1144 if last_header_value(input, name) == Some(new) {
1145 continue;
1146 }
1147 match name {
1148 // Origin-form only: non-empty, leading '/', parseable as a
1149 // path-and-query (rejects bad characters and fragments).
1150 // An absolute-form target would move the dial target — the
1151 // upstream pick is the balancer's, never the plugin's.
1152 ":path" => match if new.is_empty() || !new.starts_with('/') {
1153 None
1154 } else {
1155 new.parse::<PathAndQuery>().ok()
1156 } {
1157 Some(target) => rewrite.path = Some(target),
1158 None => {
1159 return Err(Box::new(invalid_rewrite(obs, rid, route, plugin, name)));
1160 }
1161 },
1162 ":method" => match new.parse::<hyper::Method>() {
1163 Ok(method) => rewrite.method = Some(method),
1164 Err(_) => {
1165 return Err(Box::new(invalid_rewrite(obs, rid, route, plugin, name)));
1166 }
1167 },
1168 // The authority grammar, converted to the header value the
1169 // forward will carry: either parse failing (empty,
1170 // userinfo, bad bytes) is an unusable Host — fail closed.
1171 ":authority" => match new
1172 .parse::<http::uri::Authority>()
1173 .ok()
1174 .and_then(|a| HeaderValue::from_str(a.as_str()).ok())
1175 {
1176 Some(host) => rewrite.authority = Some(host),
1177 None => {
1178 return Err(Box::new(invalid_rewrite(obs, rid, route, plugin, name)));
1179 }
1180 },
1181 _ => unreachable!("TARGET_PSEUDO_HEADERS enumerates every match arm"),
1182 }
1183 }
1184 Ok(rewrite)
1185}
1186
1187/// The fail-closed 500 for a plugin's invalid target rewrite (empty,
1188/// non-`/`-prefixed, or unparseable `:path`; a non-token `:method`;
1189/// an unparseable `:authority`): one failure metric (`invalid_rewrite`
1190/// — attributed to the first `request_headers` plugin, falling back to
1191/// the route name exactly like the body-cap attribution), one
1192/// server-side log naming the pseudo-header but never the value, and
1193/// the generic envelope. The request does not proceed to the upstream.
1194fn invalid_rewrite(
1195 obs: &Observability,
1196 rid: &str,
1197 route: &str,
1198 plugin: Option<&str>,
1199 name: &str,
1200) -> Response<ProxyBody> {
1201 let attribution = plugin.unwrap_or(route);
1202 tracing::warn!(
1203 code = "plugin_failed",
1204 request_id = %rid,
1205 route = %route,
1206 plugin = %attribution,
1207 pseudo_header = name,
1208 "plugin wrote an invalid target rewrite; failing closed"
1209 );
1210 obs.record_plugin_failure(attribution, "invalid_rewrite");
1211 plugin_failed_response(rid)
1212}
1213
1214/// Why a response's `response_body` plugin phase is skipped (DW-157):
1215/// the body is streaming (SSE or un-framed — buffering it would stall
1216/// the route) or content-encoded (opaque bytes to the plugin; dwara
1217/// never decompresses on this path). `None` = bufferable.
1218fn plugin_body_skip_reason(resp: &Response<ProxyBody>) -> Option<&'static str> {
1219 let headers = resp.headers();
1220 if headers.contains_key(hyper::header::CONTENT_ENCODING) {
1221 return Some("content_encoded");
1222 }
1223 let sse = headers
1224 .get(hyper::header::CONTENT_TYPE)
1225 .and_then(|v| v.to_str().ok())
1226 .is_some_and(|ct| {
1227 ct.split(';')
1228 .next()
1229 .is_some_and(|mime| mime.trim().eq_ignore_ascii_case("text/event-stream"))
1230 });
1231 if sse {
1232 return Some("streaming");
1233 }
1234 if !headers.contains_key(hyper::header::CONTENT_LENGTH) {
1235 // No declared length: chunked or an open-ended stream. The
1236 // phase needs the WHOLE body up front, which is exactly what
1237 // these bodies never promise.
1238 return Some("streaming");
1239 }
1240 None
1241}
1242
1243/// A plugin DECIDED the request's answer (`send_http_response` or a
1244/// native LocalResponse): not a failure — log at info for the audit
1245/// trail; the access-log `plugin_short_circuit` flag is set by the
1246/// caller (the proxy).
1247fn plugin_decision(rid: &str, route: &str) {
1248 tracing::info!(
1249 code = "plugin_short_circuit",
1250 request_id = %rid,
1251 route = %route,
1252 "plugin answered the request locally; the upstream was not dialed"
1253 );
1254}
1255
1256/// A plugin failed (WASM trap or native filter error): fail-closed 500
1257/// `plugin_failed`, one failure metric, one server-side log naming the
1258/// plugin. The trap detail never reaches the client.
1259fn plugin_error(
1260 obs: &Observability,
1261 rid: &str,
1262 route: &str,
1263 plugin: &str,
1264 message: &str,
1265) -> Response<ProxyBody> {
1266 tracing::warn!(
1267 code = "plugin_failed",
1268 request_id = %rid,
1269 route = %route,
1270 plugin = %plugin,
1271 reason = %message,
1272 "plugin trapped or errored; failing closed"
1273 );
1274 obs.record_plugin_failure(plugin, "trap");
1275 plugin_failed_response(rid)
1276}
1277
1278fn plugin_failed_response(rid: &str) -> Response<ProxyBody> {
1279 simple(
1280 StatusCode::INTERNAL_SERVER_ERROR,
1281 "plugin_failed",
1282 "a plugin on this route failed",
1283 rid,
1284 )
1285}
1286
1287fn unavailable_response(rid: &str) -> Response<ProxyBody> {
1288 simple(
1289 StatusCode::INTERNAL_SERVER_ERROR,
1290 "plugin_unavailable",
1291 "a plugin on this route is unavailable",
1292 rid,
1293 )
1294}
1295
1296/// The fail-closed 500 for an unusable plugin (missing, crashed,
1297/// disabled, unregistered native, failed instantiation): metric +
1298/// server-side log (the specific reason) + the generic envelope.
1299fn unavailable(
1300 obs: &Observability,
1301 name: &str,
1302 reason: &'static str,
1303 route: &Route,
1304 rid: &str,
1305) -> Response<ProxyBody> {
1306 tracing::warn!(
1307 code = "plugin_unavailable",
1308 request_id = %rid,
1309 route = %route.name,
1310 plugin = %name,
1311 reason = reason,
1312 "route references an unusable plugin; failing closed"
1313 );
1314 obs.record_plugin_failure(name, reason);
1315 unavailable_response(rid)
1316}
1317
1318/// The fail-closed 500 for a body over the plugin buffering cap.
1319/// `plugin` is the first plugin declaring the phase (deterministic
1320/// attribution; falls back to the route name — the phase, not one
1321/// plugin, is what could not be satisfied — keeping the metric's label
1322/// space config-bounded either way).
1323fn body_too_large(
1324 obs: &Observability,
1325 rid: &str,
1326 route: &str,
1327 cap: u64,
1328 plugin: Option<&str>,
1329) -> Response<ProxyBody> {
1330 let name = plugin.unwrap_or(route);
1331 tracing::warn!(
1332 code = "plugin_body_too_large",
1333 request_id = %rid,
1334 route = %route,
1335 plugin = name,
1336 cap_bytes = cap,
1337 "body exceeds the plugin buffering cap; failing closed"
1338 );
1339 obs.record_plugin_failure(name, "body_too_large");
1340 simple(
1341 StatusCode::INTERNAL_SERVER_ERROR,
1342 "plugin_body_too_large",
1343 &format!("body exceeds the plugin buffering cap of {cap} bytes"),
1344 rid,
1345 )
1346}
1347
1348/// Build the response for a plugin's local response
1349/// (`proxy_send_http_response` / native LocalResponse): the plugin's
1350/// status, headers, and body verbatim; a `content-type` default of
1351/// `application/json` when it set none (mirroring the nano-service
1352/// response builder).
1353fn local_response(resp: LocalResponse, rid: &str) -> Response<ProxyBody> {
1354 let mut builder = Response::builder()
1355 .status(StatusCode::from_u16(resp.status).unwrap_or(StatusCode::INTERNAL_SERVER_ERROR));
1356 let has_content_type = resp
1357 .headers
1358 .iter()
1359 .any(|(k, _)| k.eq_ignore_ascii_case("content-type"));
1360 if !has_content_type && !resp.body.is_empty() {
1361 builder = builder.header(hyper::header::CONTENT_TYPE, "application/json");
1362 }
1363 for (name, value) in &resp.headers {
1364 if let (Ok(n), Ok(v)) = (
1365 HeaderName::from_bytes(name.as_bytes()),
1366 HeaderValue::from_str(value),
1367 ) {
1368 builder = builder.header(n, v);
1369 }
1370 }
1371 match builder.body(ProxyBody::Full(Full::new(Bytes::from(resp.body)))) {
1372 Ok(resp) => resp,
1373 Err(_) => plugin_failed_response(rid),
1374 }
1375}
1376
1377/// Write a plugin phase's header output back into the live
1378/// [`HeaderMap`], applying ONLY what the chain actually changed
1379/// (DW-157). The `(String, String)` list the sandbox sees cannot
1380/// represent non-UTF-8 header values (legal obs-text), so a
1381/// clear-and-rebuild would blank them. Instead:
1382///
1383/// - when the chain returned its input unchanged (the common case — no
1384/// plugin in the phase, or pure pass-through) the map is not touched
1385/// at all;
1386/// - otherwise the lists are diffed per name: only names whose VALUE
1387/// LIST the chain changed (added, removed, reordered, or rewritten)
1388/// are rewritten in the map; every other header keeps its original
1389/// bytes, non-UTF-8 values included.
1390///
1391/// Pseudo-headers (leading `:`) are excluded from both sides — the
1392/// ordinary header map never carries them (the request-side target
1393/// pseudo-headers are applied separately; see
1394/// [`pseudo_header_rewrite`]). Unparseable names/values a changed name
1395/// produced are skipped (the sandbox cannot crash the gateway with a
1396/// bad header).
1397fn apply_header_changes(
1398 headers: &mut HeaderMap,
1399 input: &[(String, String)],
1400 output: &[(String, String)],
1401) {
1402 if input == output {
1403 return;
1404 }
1405 let before = group_header_list(input);
1406 let after = group_header_list(output);
1407 for (name, new_values) in &after {
1408 let unchanged = before
1409 .iter()
1410 .find(|(n, _)| n == name)
1411 .is_some_and(|(_, old_values)| old_values == new_values);
1412 if unchanged {
1413 continue;
1414 }
1415 // The chain changed this name's value list: rewrite it in the
1416 // map. `remove` drops every value of the name; the plugin's
1417 // list is the new truth for it.
1418 headers.remove(name.as_str());
1419 for value in new_values {
1420 if let (Ok(n), Ok(v)) = (
1421 HeaderName::from_bytes(name.as_bytes()),
1422 HeaderValue::from_str(value),
1423 ) {
1424 headers.append(n, v);
1425 }
1426 }
1427 }
1428 // Names the chain REMOVED (present before, absent after).
1429 for (name, _) in &before {
1430 if !after.iter().any(|(n, _)| n == name) {
1431 headers.remove(name.as_str());
1432 }
1433 }
1434}
1435
1436/// Set `Content-Length` to the (rewritten) body length after a body
1437/// phase replaces the body with buffered bytes. Framing is exact: the
1438/// buffered bytes ARE the body now.
1439fn set_content_length(headers: &mut HeaderMap, len: usize) {
1440 headers.remove(hyper::header::TRANSFER_ENCODING);
1441 if let Ok(v) = HeaderValue::from_str(&len.to_string()) {
1442 headers.insert(hyper::header::CONTENT_LENGTH, v);
1443 }
1444}
1445
1446/// The uniform gateway error envelope (a local copy of the proxy's
1447/// `simple` responder: this module builds its own responses and the
1448/// proxy's helper is private to that module).
1449fn simple(status: StatusCode, code: &str, msg: &str, rid: &str) -> Response<ProxyBody> {
1450 Response::builder()
1451 .status(status)
1452 .header(hyper::header::CONTENT_TYPE, "application/json")
1453 .body(ProxyBody::Full(Full::new(
1454 crate::observability::envelope_body(code, msg, rid),
1455 )))
1456 .expect("static error body is valid")
1457}