1use std::sync::atomic::{AtomicU64, Ordering};
53use std::sync::{Arc, Mutex, OnceLock};
54
55use wasmtime::{Config, Engine, Instance, InstancePre, Linker, Module, Store, StoreLimits, StoreLimitsBuilder, Trap, TypedFunc};
56
57use crate::application::Application;
58use crate::header::Header;
59use crate::middleware::Middleware;
60use crate::request::Request;
61use crate::response::{Response, STATUS_CODE_REASON_PHRASE};
62use crate::server::ConnectionInfo;
63
64pub const DEFAULT_FUEL: u64 = 50_000_000;
66pub const DEFAULT_MEMORY_MB: u64 = 16;
68pub const MAX_OUTPUT: usize = 1024 * 1024;
70
71fn engine() -> &'static Engine {
72 static ENGINE: OnceLock<Engine> = OnceLock::new();
73 ENGINE.get_or_init(|| {
74 let mut config = Config::new();
75 config.consume_fuel(true);
76 Engine::new(&config).expect("a wasmtime engine with fuel")
77 })
78}
79
80#[derive(Debug, Clone, PartialEq, Eq)]
82pub enum PluginError {
83 Fuel,
85 Memory,
87 Trap(String),
89 Output(String),
91}
92
93impl PluginError {
94 fn kind(&self) -> &'static str {
95 match self {
96 PluginError::Fuel => "fuel",
97 PluginError::Memory => "memory",
98 PluginError::Trap(_) => "trap",
99 PluginError::Output(_) => "output",
100 }
101 }
102}
103
104impl std::fmt::Display for PluginError {
105 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 match self {
107 PluginError::Fuel => write!(f, "ran out of fuel"),
108 PluginError::Memory => write!(f, "hit its memory limit"),
109 PluginError::Trap(t) => write!(f, "trapped: {t}"),
110 PluginError::Output(o) => write!(f, "bad output: {o}"),
111 }
112 }
113}
114
115#[derive(Debug, Clone, PartialEq, Eq)]
117pub enum RequestAction {
118 Pass,
119 Modify { edits: Vec<Edit>, uri: Option<String> },
121 Respond { status: u16, headers: Vec<(String, String)>, body: Vec<u8> },
122}
123
124#[derive(Debug, Clone, PartialEq, Eq)]
126pub struct ResponseAction {
127 pub edits: Vec<Edit>,
128 pub status: Option<u16>,
129}
130
131#[derive(Debug, Clone, PartialEq, Eq)]
132pub enum Edit {
133 Set(String, String),
134 Add(String, String),
135 Remove(String),
136}
137
138fn valid_token(s: &str) -> bool {
139 !s.is_empty() && !s.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0)
140}
141
142fn header_pair(rest: &str) -> Result<(String, String), PluginError> {
143 let (name, value) = rest.split_once(':').ok_or_else(|| PluginError::Output(format!("expected name: value, got {rest:?}")))?;
144 let (name, value) = (name.trim(), value.trim());
145 if !valid_token(name) || name.contains(' ') || value.bytes().any(|b| b == b'\r' || b == b'\n' || b == 0) {
146 return Err(PluginError::Output(format!("bad header {name:?}")));
147 }
148 Ok((name.to_string(), value.to_string()))
149}
150
151fn status_code(s: &str) -> Result<u16, PluginError> {
152 match s.trim().parse::<u16>() {
153 Ok(code) if (100..=599).contains(&code) => Ok(code),
154 _ => Err(PluginError::Output(format!("bad status {s:?}"))),
155 }
156}
157
158pub fn parse_request_output(output: &[u8]) -> Result<RequestAction, PluginError> {
160 if output.is_empty() {
161 return Ok(RequestAction::Pass);
162 }
163 let text_end = output.len();
164 let mut edits = Vec::new();
165 let mut uri = None;
166 let mut offset = 0;
167 while offset < text_end {
168 let end = output[offset..].iter().position(|&b| b == b'\n').map_or(text_end, |p| offset + p);
169 let line = std::str::from_utf8(&output[offset..end]).map_err(|_| PluginError::Output("not UTF-8".to_string()))?;
170 let line = line.strip_suffix('\r').unwrap_or(line);
171 offset = (end + 1).min(text_end);
172 if line.is_empty() {
173 continue;
174 }
175 let (directive, rest) = line.split_once(' ').unwrap_or((line, ""));
176 match directive {
177 "set" => {
178 let (n, v) = header_pair(rest)?;
179 edits.push(Edit::Set(n, v));
180 }
181 "add" => {
182 let (n, v) = header_pair(rest)?;
183 edits.push(Edit::Add(n, v));
184 }
185 "remove" if valid_token(rest.trim()) => edits.push(Edit::Remove(rest.trim().to_string())),
186 "uri" if rest.starts_with('/') && valid_token(rest) && !rest.contains(' ') => uri = Some(rest.to_string()),
187 "respond" => {
188 let status = status_code(rest)?;
189 let mut headers = Vec::new();
190 loop {
191 if offset >= text_end {
192 return Ok(RequestAction::Respond { status, headers, body: Vec::new() });
193 }
194 let end = output[offset..].iter().position(|&b| b == b'\n').map_or(text_end, |p| offset + p);
195 let line = std::str::from_utf8(&output[offset..end]).map_err(|_| PluginError::Output("not UTF-8".to_string()))?;
196 let line = line.strip_suffix('\r').unwrap_or(line);
197 offset = (end + 1).min(text_end);
198 if line.is_empty() {
199 let body = if end < text_end { output[offset..].to_vec() } else { Vec::new() };
200 return Ok(RequestAction::Respond { status, headers, body });
201 }
202 headers.push(header_pair(line)?);
203 }
204 }
205 _ => return Err(PluginError::Output(format!("unknown directive {line:?}"))),
206 }
207 }
208 Ok(if edits.is_empty() && uri.is_none() { RequestAction::Pass } else { RequestAction::Modify { edits, uri } })
209}
210
211pub fn parse_response_output(output: &[u8]) -> Result<ResponseAction, PluginError> {
213 let text = std::str::from_utf8(output).map_err(|_| PluginError::Output("not UTF-8".to_string()))?;
214 let mut action = ResponseAction { edits: Vec::new(), status: None };
215 for line in text.lines() {
216 if line.is_empty() {
217 continue;
218 }
219 let (directive, rest) = line.split_once(' ').unwrap_or((line, ""));
220 match directive {
221 "set" => {
222 let (n, v) = header_pair(rest)?;
223 action.edits.push(Edit::Set(n, v));
224 }
225 "add" => {
226 let (n, v) = header_pair(rest)?;
227 action.edits.push(Edit::Add(n, v));
228 }
229 "remove" if valid_token(rest.trim()) => action.edits.push(Edit::Remove(rest.trim().to_string())),
230 "status" => action.status = Some(status_code(rest)?),
231 _ => return Err(PluginError::Output(format!("unknown directive {line:?}"))),
232 }
233 }
234 Ok(action)
235}
236
237fn apply(headers: &mut Vec<Header>, edits: &[Edit]) {
238 for edit in edits {
239 match edit {
240 Edit::Set(name, value) => {
241 headers.retain(|h| !h.name.eq_ignore_ascii_case(name));
242 headers.push(Header::new(name.clone(), value.clone()));
243 }
244 Edit::Add(name, value) => headers.push(Header::new(name.clone(), value.clone())),
245 Edit::Remove(name) => headers.retain(|h| !h.name.eq_ignore_ascii_case(name)),
246 }
247 }
248}
249
250fn request_input(request: &Request, connection: &ConnectionInfo) -> Vec<u8> {
251 let mut out = format!("{} {} {}\n{}\n", request.method, request.request_uri, request.http_version, crate::ip_filter::client_ip(request, &connection.client.ip)).into_bytes();
252 for h in &request.headers {
253 out.extend_from_slice(h.name.to_ascii_lowercase().as_bytes());
254 out.extend_from_slice(b": ");
255 out.extend_from_slice(h.value.as_bytes());
256 out.push(b'\n');
257 }
258 out.push(b'\n');
259 out
260}
261
262fn response_input(response: &Response) -> Vec<u8> {
263 let mut out = format!("{}\n", response.status_code).into_bytes();
264 let content_type = response.content_range_list.first().map(|p| p.content_type.clone()).filter(|c| !c.is_empty());
265 if let Some(ct) = content_type.filter(|_| !response.headers.iter().any(|h| h.name.eq_ignore_ascii_case("content-type"))) {
266 out.extend_from_slice(format!("content-type: {ct}\n").as_bytes());
267 }
268 for h in &response.headers {
269 out.extend_from_slice(h.name.to_ascii_lowercase().as_bytes());
270 out.extend_from_slice(b": ");
271 out.extend_from_slice(h.value.as_bytes());
272 out.push(b'\n');
273 }
274 out.push(b'\n');
275 out
276}
277
278struct State {
279 limits: StoreLimits,
280}
281
282struct Call {
284 store: Store<State>,
285 instance: Instance,
286}
287
288impl Call {
289 fn error(&self, e: wasmtime::Error) -> PluginError {
290 match e.downcast_ref::<Trap>() {
291 Some(Trap::OutOfFuel) => PluginError::Fuel,
292 Some(Trap::AllocationTooLarge) => PluginError::Memory,
293 Some(trap) => PluginError::Trap(trap.to_string()),
294 None => {
295 let chain = format!("{e:#}");
298 if chain.contains("grow") || (chain.contains("memory") && chain.contains("limit")) {
299 PluginError::Memory
300 } else {
301 PluginError::Trap(chain.lines().next().unwrap_or_default().to_string())
302 }
303 }
304 }
305 }
306
307 fn write_input(&mut self, input: &[u8]) -> Result<(i32, i32), PluginError> {
308 let alloc: TypedFunc<i32, i32> = self
309 .instance
310 .get_typed_func(&mut self.store, "rws_alloc")
311 .map_err(|e| PluginError::Output(format!("no rws_alloc: {e}")))?;
312 let len = i32::try_from(input.len()).map_err(|_| PluginError::Output("input too large".to_string()))?;
313 let ptr = alloc.call(&mut self.store, len).map_err(|e| self.error(e))?;
314 let memory = self.instance.get_memory(&mut self.store, "memory").ok_or_else(|| PluginError::Output("no memory export".to_string()))?;
315 memory
316 .write(&mut self.store, ptr as u32 as usize, input)
317 .map_err(|_| PluginError::Output("rws_alloc returned a buffer outside memory".to_string()))?;
318 Ok((ptr, len))
319 }
320
321 fn hook(&mut self, name: &str, input: &[u8]) -> Result<Option<Vec<u8>>, PluginError> {
323 let Ok(func) = self.instance.get_typed_func::<(i32, i32), i64>(&mut self.store, name) else {
324 return Ok(None);
325 };
326 let (ptr, len) = self.write_input(input)?;
327 let packed = func.call(&mut self.store, (ptr, len)).map_err(|e| self.error(e))?;
328 if packed == 0 {
329 return Ok(Some(Vec::new()));
330 }
331 let (out_ptr, out_len) = ((packed as u64 >> 32) as usize, (packed as u64 & 0xffff_ffff) as usize);
332 if out_len > MAX_OUTPUT {
333 return Err(PluginError::Output(format!("output of {out_len} bytes is over {MAX_OUTPUT}")));
334 }
335 let memory = self.instance.get_memory(&mut self.store, "memory").ok_or_else(|| PluginError::Output("no memory export".to_string()))?;
336 let data = memory.data(&self.store);
337 let end = out_ptr.checked_add(out_len).filter(|&e| e <= data.len()).ok_or_else(|| PluginError::Output("output outside memory".to_string()))?;
338 Ok(Some(data[out_ptr..end].to_vec()))
339 }
340}
341
342struct Plugin {
343 name: String,
344 pre: InstancePre<State>,
345 fuel: u64,
346 memory_bytes: usize,
347 config: Vec<u8>,
348 has_response_hook: bool,
349}
350
351#[derive(Clone)]
353pub struct WasmLayer {
354 plugin: Arc<Plugin>,
355}
356
357impl std::fmt::Debug for WasmLayer {
358 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
359 f.debug_struct("WasmLayer").field("name", &self.plugin.name).field("fuel", &self.plugin.fuel).finish_non_exhaustive()
360 }
361}
362
363impl WasmLayer {
364 pub fn new(name: impl Into<String>, wasm: &[u8]) -> Result<WasmLayer, String> {
368 let name = name.into();
369 let module = Module::new(engine(), wasm).map_err(|e| format!("plugin {name}: not a valid WebAssembly module: {e}"))?;
370 if let Some(import) = module.imports().next() {
371 return Err(format!(
372 "plugin {name}: imports {}::{}; plugins get no imports (no WASI)",
373 import.module(),
374 import.name()
375 ));
376 }
377 let exports: Vec<(String, wasmtime::ExternType)> = module.exports().map(|e| (e.name().to_string(), e.ty())).collect();
378 let func = |n: &str| exports.iter().find(|(name, _)| name == n).and_then(|(_, t)| t.func().cloned());
379 let shape = |n: &str, params: &[wasmtime::ValType], results: &[wasmtime::ValType]| {
380 func(n).is_some_and(|f| {
381 f.params().map(|p| p.to_string()).collect::<Vec<_>>() == params.iter().map(|p| p.to_string()).collect::<Vec<_>>()
382 && f.results().map(|p| p.to_string()).collect::<Vec<_>>() == results.iter().map(|p| p.to_string()).collect::<Vec<_>>()
383 })
384 };
385 use wasmtime::ValType::{I32, I64};
386 if !exports.iter().any(|(n, t)| n == "memory" && t.memory().is_some()) {
387 return Err(format!("plugin {name}: exports no memory"));
388 }
389 if !shape("rws_alloc", &[I32], &[I32]) {
390 return Err(format!("plugin {name}: needs rws_alloc(len: i32) -> i32"));
391 }
392 if !shape("rws_on_request", &[I32, I32], &[I64]) {
393 return Err(format!("plugin {name}: needs rws_on_request(ptr: i32, len: i32) -> i64"));
394 }
395 if func("rws_on_response").is_some() && !shape("rws_on_response", &[I32, I32], &[I64]) {
396 return Err(format!("plugin {name}: rws_on_response must be (ptr: i32, len: i32) -> i64"));
397 }
398 if func("rws_init").is_some() && !shape("rws_init", &[I32, I32], &[]) {
399 return Err(format!("plugin {name}: rws_init must be (ptr: i32, len: i32)"));
400 }
401 let pre = Linker::new(engine()).instantiate_pre(&module).map_err(|e| format!("plugin {name}: {e}"))?;
402 Ok(WasmLayer {
403 plugin: Arc::new(Plugin {
404 has_response_hook: func("rws_on_response").is_some(),
405 name,
406 pre,
407 fuel: DEFAULT_FUEL,
408 memory_bytes: (DEFAULT_MEMORY_MB * 1024 * 1024) as usize,
409 config: Vec::new(),
410 }),
411 })
412 }
413
414 pub fn from_file(path: &str) -> Result<WasmLayer, String> {
416 let wasm = std::fs::read(path).map_err(|e| format!("plugin {path}: {e}"))?;
417 let name = std::path::Path::new(path).file_stem().map(|s| s.to_string_lossy().to_string()).unwrap_or_else(|| path.to_string());
418 WasmLayer::new(name, &wasm)
419 }
420
421 fn edit(self, f: impl FnOnce(&mut Plugin)) -> WasmLayer {
422 let mut plugin = Plugin {
423 name: self.plugin.name.clone(),
424 pre: self.plugin.pre.clone(),
425 fuel: self.plugin.fuel,
426 memory_bytes: self.plugin.memory_bytes,
427 config: self.plugin.config.clone(),
428 has_response_hook: self.plugin.has_response_hook,
429 };
430 f(&mut plugin);
431 WasmLayer { plugin: Arc::new(plugin) }
432 }
433
434 pub fn fuel(self, fuel: u64) -> WasmLayer {
436 self.edit(|p| p.fuel = fuel.max(1))
437 }
438
439 pub fn memory_mb(self, mb: u64) -> WasmLayer {
441 self.edit(|p| p.memory_bytes = (mb.max(1) * 1024 * 1024) as usize)
442 }
443
444 pub fn config(self, config: impl Into<String>) -> WasmLayer {
446 let config = config.into().into_bytes();
447 self.edit(|p| p.config = config)
448 }
449
450 pub fn name(&self) -> &str {
451 &self.plugin.name
452 }
453
454 fn instantiate(&self) -> Result<Call, PluginError> {
455 let p = &self.plugin;
456 let limits = StoreLimitsBuilder::new().memory_size(p.memory_bytes).instances(1).tables(4).memories(1).trap_on_grow_failure(true).build();
457 let mut store = Store::new(engine(), State { limits });
458 store.limiter(|s| &mut s.limits);
459 store.set_fuel(p.fuel).map_err(|e| PluginError::Trap(e.to_string()))?;
460 let instance = match p.pre.instantiate(&mut store) {
461 Ok(instance) => instance,
462 Err(e) => {
463 let text = format!("{e:#}");
464 return Err(if text.contains("memory") { PluginError::Memory } else if text.contains("fuel") { PluginError::Fuel } else { PluginError::Trap(text) });
465 }
466 };
467 let mut call = Call { store, instance };
468 if !p.config.is_empty() {
469 if let Ok(init) = call.instance.get_typed_func::<(i32, i32), ()>(&mut call.store, "rws_init") {
470 let (ptr, len) = call.write_input(&p.config)?;
471 init.call(&mut call.store, (ptr, len)).map_err(|e| call.error(e))?;
472 }
473 }
474 Ok(call)
475 }
476
477 pub fn on_request(&self, request: &Request, connection: &ConnectionInfo) -> Result<RequestAction, PluginError> {
479 let mut call = self.instantiate()?;
480 let output = call.hook("rws_on_request", &request_input(request, connection))?.unwrap_or_default();
481 parse_request_output(&output)
482 }
483
484 fn failed(&self, hook: &'static str, e: &PluginError) -> Response {
485 metrics::error(&self.plugin.name, e.kind());
486 eprintln!("wasm plugin {} ({hook}) {e}", self.plugin.name);
487 crate::error::problem(STATUS_CODE_REASON_PHRASE.n500_internal_server_error, "a plugin failed")
488 }
489}
490
491impl Middleware for WasmLayer {
492 fn handle(&self, request: &Request, connection: &ConnectionInfo, next: &dyn Application) -> Result<Response, String> {
493 metrics::call(&self.plugin.name, "request");
494 let mut call = match self.instantiate() {
495 Ok(call) => call,
496 Err(e) => return Ok(self.failed("request", &e)),
497 };
498 let action = call
499 .hook("rws_on_request", &request_input(request, connection))
500 .and_then(|output| parse_request_output(&output.unwrap_or_default()));
501 let mut response = match action {
502 Err(e) => return Ok(self.failed("request", &e)),
503 Ok(RequestAction::Respond { status, headers, body }) => {
504 let reason = STATUS_CODE_REASON_PHRASE_FOR(status);
505 let content_type = headers
506 .iter()
507 .find(|(n, _)| n.eq_ignore_ascii_case("content-type"))
508 .map(|(_, v)| v.clone())
509 .unwrap_or_else(|| "text/plain".to_string());
510 let mut response = Response::get_response(reason, None, Some(vec![crate::range::Range::get_content_range(body, content_type)]));
511 response.status_code = status as i16;
512 for (name, value) in headers.into_iter().filter(|(n, _)| !n.eq_ignore_ascii_case("content-type") && !n.eq_ignore_ascii_case("content-length")) {
513 response.headers.push(Header::new(name, value));
514 }
515 return Ok(response);
516 }
517 Ok(RequestAction::Pass) => next.execute(request, connection)?,
518 Ok(RequestAction::Modify { edits, uri }) => {
519 let mut changed = request.clone();
520 apply(&mut changed.headers, &edits);
521 if let Some(uri) = uri {
522 changed.request_uri = uri;
523 }
524 next.execute(&changed, connection)?
525 }
526 };
527 if self.plugin.has_response_hook {
528 metrics::call(&self.plugin.name, "response");
529 let action = call
530 .hook("rws_on_response", &response_input(&response))
531 .and_then(|output| parse_response_output(&output.unwrap_or_default()));
532 match action {
533 Err(e) => return Ok(self.failed("response", &e)),
534 Ok(action) => {
535 apply(&mut response.headers, &action.edits);
536 if let Some(status) = action.status {
537 response.status_code = status as i16;
538 response.reason_phrase = STATUS_CODE_REASON_PHRASE_FOR(status).reason_phrase.to_string();
539 }
540 }
541 }
542 }
543 Ok(response)
544 }
545
546 fn accepts_streamed_body(&self, _request: &Request, _connection: &ConnectionInfo) -> bool {
548 true
549 }
550}
551
552#[allow(non_snake_case)]
555fn STATUS_CODE_REASON_PHRASE_FOR(status: u16) -> &'static crate::response::StatusCodeReasonPhrase {
556 crate::response::Response::status_code_reason_phrase_list()
557 .into_iter()
558 .find(|s| *s.status_code as u16 == status)
559 .unwrap_or(STATUS_CODE_REASON_PHRASE.n200_ok)
560}
561
562#[doc(hidden)]
564pub mod metrics {
565 use super::*;
566 use std::collections::BTreeMap;
567
568 #[derive(Default)]
569 struct Stats {
570 calls: BTreeMap<(String, &'static str), u64>,
571 errors: BTreeMap<(String, &'static str), u64>,
572 }
573
574 static ANY: AtomicU64 = AtomicU64::new(0);
575
576 fn stats() -> &'static Mutex<Stats> {
577 static STATS: OnceLock<Mutex<Stats>> = OnceLock::new();
578 STATS.get_or_init(Mutex::default)
579 }
580
581 #[doc(hidden)]
582 pub fn call(plugin: &str, hook: &'static str) {
583 if ANY.fetch_add(1, Ordering::Relaxed) == 0 {
584 crate::metrics::register_section(crate::metrics::section::WASM_PLUGINS, prometheus_text);
585 }
586 *stats().lock().unwrap_or_else(|e| e.into_inner()).calls.entry((plugin.to_string(), hook)).or_default() += 1;
587 }
588
589 #[doc(hidden)]
590 pub fn error(plugin: &str, kind: &'static str) {
591 *stats().lock().unwrap_or_else(|e| e.into_inner()).errors.entry((plugin.to_string(), kind)).or_default() += 1;
592 }
593
594 fn label(value: &str) -> String {
595 value.replace('\\', "\\\\").replace('"', "\\\"").replace('\n', "\\n")
596 }
597
598 #[doc(hidden)]
599 pub fn prometheus_text() -> String {
600 if ANY.load(Ordering::Relaxed) == 0 {
601 return String::new();
602 }
603 let stats = stats().lock().unwrap_or_else(|e| e.into_inner());
604 let mut out = String::from("# HELP rws_wasm_plugin_calls_total WebAssembly plugin hook calls\n# TYPE rws_wasm_plugin_calls_total counter\n");
605 for ((plugin, hook), n) in &stats.calls {
606 out.push_str(&format!("rws_wasm_plugin_calls_total{{plugin=\"{}\",hook=\"{hook}\"}} {n}\n", label(plugin)));
607 }
608 if !stats.errors.is_empty() {
609 out.push_str("# HELP rws_wasm_plugin_errors_total WebAssembly plugin calls that failed (answered 500), by kind: fuel, memory, trap, output\n# TYPE rws_wasm_plugin_errors_total counter\n");
610 for ((plugin, kind), n) in &stats.errors {
611 out.push_str(&format!("rws_wasm_plugin_errors_total{{plugin=\"{}\",kind=\"{kind}\"}} {n}\n", label(plugin)));
612 }
613 }
614 out
615 }
616}
617
618#[cfg(test)]
619mod tests;