1use std::sync::{Arc, Condvar, Mutex};
16use std::time::{Duration, Instant};
17
18use tracing::warn;
19use waf_core::{Decision, Phase, RequestContext, WafModule};
20use wasmi::{
21 Config, Engine, Instance, Linker, Module, Store, StoreLimitsBuilder, Val, ValType,
22};
23
24use crate::abi::Status;
25use crate::host::{add_to_linker, HostState, LocalResponse};
26use crate::marshal::{header_value, RequestView};
27use crate::report::{self, ImportReport};
28
29const ROOT_CTX: i32 = 1;
30const HTTP_CTX: i32 = 2;
31
32#[derive(Debug, Clone)]
34pub struct WasmOptions {
35 pub pool_size: usize,
36 pub fuel_per_request: u64,
37 pub max_memory_bytes: usize,
38 pub checkout_timeout: Duration,
39}
40
41impl Default for WasmOptions {
42 fn default() -> Self {
43 WasmOptions {
44 pool_size: 4,
45 fuel_per_request: 10_000_000,
46 max_memory_bytes: 16 * 1024 * 1024,
47 checkout_timeout: Duration::from_millis(100),
48 }
49 }
50}
51
52#[derive(Debug)]
54pub enum WasmError {
55 Compile(String),
56 Instantiate(String),
57 MissingMemory,
58 MissingAllocator,
59 VmStart(String),
60}
61
62impl std::fmt::Display for WasmError {
63 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64 match self {
65 WasmError::Compile(e) => write!(f, "wasm compile failed: {e}"),
66 WasmError::Instantiate(e) => write!(f, "wasm instantiate failed: {e}"),
67 WasmError::MissingMemory => write!(f, "guest exports no linear memory"),
68 WasmError::MissingAllocator => {
69 write!(f, "guest exports no allocator (proxy_on_memory_allocate/malloc)")
70 }
71 WasmError::VmStart(e) => write!(f, "guest vm/configure start failed: {e}"),
72 }
73 }
74}
75
76impl std::error::Error for WasmError {}
77
78struct Pooled {
79 store: Store<HostState>,
80 instance: Instance,
81}
82
83struct Runtime {
84 name: String,
85 fuel: u64,
86 checkout_timeout: Duration,
87 pool: Mutex<Vec<Pooled>>,
88 available: Condvar,
89}
90
91pub struct WasmModule {
93 id: String,
94 runtime: Arc<Runtime>,
95}
96
97impl WasmModule {
98 pub fn from_bytes(
101 name: &str,
102 wasm: &[u8],
103 config: &[u8],
104 opts: &WasmOptions,
105 ) -> Result<(WasmModule, ImportReport), WasmError> {
106 let mut engine_cfg = Config::default();
107 engine_cfg.consume_fuel(true);
108 let engine = Engine::new(&engine_cfg);
109 let module =
110 Module::new(&engine, wasm).map_err(|e| WasmError::Compile(e.to_string()))?;
111
112 let report = report::classify(&module);
113
114 let mut linker: Linker<HostState> = Linker::new(&engine);
116 add_to_linker(&mut linker).map_err(|e| WasmError::Instantiate(e.to_string()))?;
117 register_stubs(&mut linker, &module)
118 .map_err(|e| WasmError::Instantiate(e.to_string()))?;
119
120 let mut pool = Vec::with_capacity(opts.pool_size.max(1));
121 for _ in 0..opts.pool_size.max(1) {
122 pool.push(build_instance(&engine, &linker, &module, config, opts)?);
123 }
124
125 let runtime = Arc::new(Runtime {
126 name: name.to_string(),
127 fuel: opts.fuel_per_request,
128 checkout_timeout: opts.checkout_timeout,
129 pool: Mutex::new(pool),
130 available: Condvar::new(),
131 });
132 Ok((WasmModule { id: format!("wasm:{name}"), runtime }, report))
133 }
134
135 fn checkout(&self) -> Option<Pooled> {
136 let rt = &self.runtime;
137 let mut guard = rt.pool.lock().unwrap();
138 let deadline = Instant::now() + rt.checkout_timeout;
139 loop {
140 if let Some(p) = guard.pop() {
141 return Some(p);
142 }
143 let now = Instant::now();
144 if now >= deadline {
145 return None;
146 }
147 let (g, _to) = rt.available.wait_timeout(guard, deadline - now).unwrap();
148 guard = g;
149 }
150 }
151
152 fn checkin(&self, p: Pooled) {
153 self.runtime.pool.lock().unwrap().push(p);
154 self.runtime.available.notify_one();
155 }
156}
157
158impl WafModule for WasmModule {
159 fn id(&self) -> &str {
160 &self.id
161 }
162
163 fn phase(&self) -> Phase {
164 Phase::Body
165 }
166
167 fn init(&mut self, _cfg: &waf_core::Config) {
168 }
170
171 fn inspect(&self, ctx: &RequestContext) -> Decision {
172 let view = view_from(ctx);
173 let mut pooled = match self.checkout() {
174 Some(p) => p,
175 None => {
177 warn!(plugin = %self.runtime.name, "wasm pool exhausted -> fail closed");
178 return reject_500(&self.runtime.name);
179 }
180 };
181 let decision =
182 run_request(&mut pooled.store, &pooled.instance, view, &ctx.request_id, self.runtime.fuel, &self.runtime.name);
183 self.checkin(pooled);
184 decision
185 }
186
187 fn structural(&self) -> bool {
189 true
190 }
191}
192
193fn build_instance(
195 engine: &Engine,
196 linker: &Linker<HostState>,
197 module: &Module,
198 config: &[u8],
199 opts: &WasmOptions,
200) -> Result<Pooled, WasmError> {
201 let limiter = StoreLimitsBuilder::new().memory_size(opts.max_memory_bytes).build();
202 let mut store = Store::new(engine, HostState::new(limiter));
203 store.limiter(|s| &mut s.limiter);
204 store.data_mut().config = config.to_vec();
205 store.set_fuel(opts.fuel_per_request).ok();
207
208 let instance = linker
209 .instantiate_and_start(&mut store, module)
210 .map_err(|e| WasmError::Instantiate(e.to_string()))?;
211
212 let mem = instance.get_memory(&store, "memory").ok_or(WasmError::MissingMemory)?;
214 let alloc = crate::abi::ALLOC_EXPORTS
215 .iter()
216 .find_map(|n| instance.get_typed_func::<i32, i32>(&store, n).ok())
217 .ok_or(WasmError::MissingAllocator)?;
218 store.data_mut().mem = Some(mem);
219 store.data_mut().alloc = Some(alloc);
220
221 for init in ["_initialize", "_start"] {
227 if instance.get_func(&store, init).is_some() {
228 call_opt(&mut store, &instance, init, &[])
229 .map_err(|e| WasmError::VmStart(format!("{init}: {e}")))?;
230 break;
231 }
232 }
233
234 call_opt(&mut store, &instance, "proxy_on_context_create", &[ROOT_CTX, 0])
236 .map_err(|e| WasmError::VmStart(e.to_string()))?;
237 if let Some(r) = call_opt(&mut store, &instance, "proxy_on_vm_start", &[ROOT_CTX, 0])
238 .map_err(|e| WasmError::VmStart(e.to_string()))?
239 {
240 if r == 0 {
241 return Err(WasmError::VmStart("proxy_on_vm_start returned failure".into()));
242 }
243 }
244 if let Some(r) =
245 call_opt(&mut store, &instance, "proxy_on_configure", &[ROOT_CTX, config.len() as i32])
246 .map_err(|e| WasmError::VmStart(e.to_string()))?
247 {
248 if r == 0 {
249 return Err(WasmError::VmStart("proxy_on_configure returned failure".into()));
250 }
251 }
252
253 Ok(Pooled { store, instance })
254}
255
256fn register_stubs(linker: &mut Linker<HostState>, module: &Module) -> Result<(), wasmi::Error> {
260 for import in module.imports() {
261 let Some(func_ty) = import.ty().func().cloned() else { continue };
262 let name = import.name();
263 if report::IMPLEMENTED.contains(&name) {
264 continue;
265 }
266 let name = name.to_string();
267 let res_types: Vec<ValType> = func_ty.results().to_vec();
268 let unimpl = res_types.len() == 1 && res_types[0] == ValType::I32;
269 let semantic = report::SEMANTIC.contains(&name.as_str());
273 let call_name = name.clone();
274 linker.func_new(
275 "env",
276 &name,
277 func_ty,
278 move |mut caller, _params: &[Val], results: &mut [Val]| {
279 if semantic && caller.data().semantic_stub_hit.is_none() {
280 caller.data_mut().semantic_stub_hit = Some(call_name.clone());
281 warn!(
282 host_call = %call_name,
283 "wasm plugin invoked a stubbed semantic host call -> Unimplemented (DEGRADED at runtime)"
284 );
285 }
286 for (slot, ty) in results.iter_mut().zip(res_types.iter()) {
287 *slot = zero_val(*ty);
288 }
289 if unimpl {
290 if let Some(s) = results.get_mut(0) {
291 *s = Val::I32(Status::Unimplemented.code());
292 }
293 }
294 Ok(())
295 },
296 )?;
297 }
298 Ok(())
299}
300
301fn zero_val(t: ValType) -> Val {
302 match t {
303 ValType::I32 => Val::I32(0),
304 ValType::I64 => Val::I64(0),
305 ValType::F32 => Val::F32(0.0f32.into()),
306 ValType::F64 => Val::F64(0.0f64.into()),
307 _ => Val::I32(0),
308 }
309}
310
311fn view_from(ctx: &RequestContext) -> RequestView {
317 let real: Vec<(String, String)> = ctx
318 .headers
319 .iter()
320 .map(|(k, v)| (k.to_ascii_lowercase(), v.clone()))
321 .collect();
322 let host = header_value(&real, "host").cloned().unwrap_or_default();
323 let path = match &ctx.query {
324 Some(q) => format!("{}?{}", ctx.path, q),
325 None => ctx.path.clone(),
326 };
327 let mut headers = vec![
328 (":method".to_string(), ctx.method.clone()),
329 (":path".to_string(), path.clone()),
330 (":authority".to_string(), host.clone()),
331 (":scheme".to_string(), "http".to_string()),
332 ];
333 headers.extend(real);
334 RequestView {
335 method: ctx.method.clone(),
336 path,
337 url_path: ctx.path.clone(),
338 protocol: ctx.http_version.clone(),
339 scheme: "http".to_string(),
340 host,
341 source_addr: ctx.client_ip.to_string(),
342 headers,
343 body: ctx.body.clone(),
344 }
345}
346
347fn run_request(
350 store: &mut Store<HostState>,
351 inst: &Instance,
352 view: RequestView,
353 request_id: &str,
354 fuel: u64,
355 name: &str,
356) -> Decision {
357 store.data_mut().reset_for(view, request_id.to_string());
358 if store.set_fuel(fuel).is_err() {
359 return reject_500(name);
360 }
361 match run_callbacks(store, inst) {
362 Ok(()) => map_decision(store.data().captured.clone(), name),
363 Err(e) => {
364 warn!(plugin = %name, error = %e, "wasm request failed (fail-closed)");
365 reject_500(name)
366 }
367 }
368}
369
370fn run_callbacks(store: &mut Store<HostState>, inst: &Instance) -> Result<(), wasmi::Error> {
371 let num_headers = store.data().req.headers.len() as i32;
372 let body_len = store.data().req.body.len() as i32;
373 call_opt(store, inst, "proxy_on_context_create", &[HTTP_CTX, ROOT_CTX])?;
374 let action = call_opt(store, inst, "proxy_on_request_headers", &[HTTP_CTX, num_headers, 1])?
375 .unwrap_or(0);
376 if action == 0 {
377 call_opt(store, inst, "proxy_on_request_body", &[HTTP_CTX, body_len, 1])?;
379 }
380 call_opt(store, inst, "proxy_on_done", &[HTTP_CTX])?;
381 call_opt(store, inst, "proxy_on_delete", &[HTTP_CTX])?;
382 Ok(())
383}
384
385fn map_decision(captured: Option<LocalResponse>, name: &str) -> Decision {
386 match captured {
387 None => Decision::Allow,
388 Some(lr) => {
389 let rule_id = format!("wasm-{name}");
390 let reason = if lr.detail.is_empty() {
391 "wasm plugin local response".to_string()
392 } else {
393 lr.detail
394 };
395 if lr.status == 403 {
396 Decision::Block { rule_id, reason }
397 } else {
398 Decision::Reject { rule_id, reason, status: lr.status, retry_after: None }
399 }
400 }
401 }
402}
403
404fn reject_500(name: &str) -> Decision {
405 Decision::Reject {
406 rule_id: format!("wasm-{name}"),
407 reason: "wasm runtime error (fail-closed)".to_string(),
408 status: 500,
409 retry_after: None,
410 }
411}
412
413fn call_opt(
417 store: &mut Store<HostState>,
418 inst: &Instance,
419 name: &str,
420 args: &[i32],
421) -> Result<Option<i32>, wasmi::Error> {
422 let Some(func) = inst.get_func(&*store, name) else { return Ok(None) };
423 let ty = func.ty(&*store);
424 let nparams = ty.params().len();
425 let params: Vec<Val> = (0..nparams).map(|i| Val::I32(args.get(i).copied().unwrap_or(0))).collect();
426 let mut results: Vec<Val> = ty.results().iter().map(|t| zero_val(*t)).collect();
427 func.call(&mut *store, ¶ms, &mut results)?;
428 Ok(Some(results.first().and_then(|v| v.i32()).unwrap_or(0)))
429}
430
431#[cfg(test)]
432mod tests {
433 use super::*;
434
435 const HTTP_CALL_WAT: &str = r#"
438 (module
439 (import "env" "proxy_http_call"
440 (func $http (param i32 i32 i32 i32 i32 i32 i32 i32 i32 i32) (result i32)))
441 (memory (export "memory") 1)
442 (func (export "proxy_on_memory_allocate") (param i32) (result i32) (i32.const 16))
443 (func (export "run") (result i32)
444 (call $http (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0)
445 (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0) (i32.const 0))))
446 "#;
447
448 #[test]
449 fn semantic_stub_invocation_returns_unimplemented_and_is_flagged() {
450 let mut cfg = Config::default();
451 cfg.consume_fuel(true);
452 let engine = Engine::new(&cfg);
453 let module = Module::new(&engine, wat::parse_str(HTTP_CALL_WAT).unwrap().as_slice()).unwrap();
454
455 let report = report::classify(&module);
457 assert_eq!(report.semantic_stubs(), vec!["proxy_http_call"]);
458
459 let mut linker = Linker::new(&engine);
460 add_to_linker(&mut linker).unwrap();
461 register_stubs(&mut linker, &module).unwrap();
462
463 let limiter = StoreLimitsBuilder::new().memory_size(64 * 1024).build();
464 let mut store = Store::new(&engine, HostState::new(limiter));
465 store.limiter(|s| &mut s.limiter);
466 store.set_fuel(1_000_000).unwrap();
467 let inst = linker.instantiate_and_start(&mut store, &module).unwrap();
468
469 let run = inst.get_typed_func::<(), i32>(&store, "run").unwrap();
470 let ret = run.call(&mut store, ()).unwrap();
471 assert_eq!(ret, Status::Unimplemented.code(), "semantic stub must answer Unimplemented");
472 assert_eq!(
473 store.data().semantic_stub_hit.as_deref(),
474 Some("proxy_http_call"),
475 "invoking a semantic stub must flag the instance as degraded at runtime"
476 );
477 }
478}