Skip to main content

wash_runtime/plugin/
wasi_http.rs

1//! HTTP server plugin for handling incoming HTTP requests.
2//!
3//! This plugin implements the `wasi:http/incoming-handler` interface, allowing
4//! WebAssembly components to handle HTTP requests. It provides a complete HTTP
5//! server implementation with support for:
6//!
7//! - Virtual hosting based on Host headers
8//! - TLS/HTTPS connections
9//! - Component isolation per request
10//! - Graceful shutdown capabilities
11//!
12//! # Architecture
13//!
14//! The HTTP server plugin works by:
15//! 1. Binding to a TCP socket and listening for connections
16//! 2. Routing requests to components based on the Host header
17//! 3. Creating isolated component instances for each request
18//! 4. Managing the request/response lifecycle through WASI-HTTP
19//! ```
20
21use std::collections::HashSet;
22use std::{collections::HashMap, net::SocketAddr, path::Path, sync::Arc};
23
24use crate::engine::workload::{ResolvedWorkload, WorkloadComponent};
25use crate::wit::WitInterface;
26use crate::wit::WitWorld;
27use crate::{engine::ctx::Ctx, plugin::HostPlugin};
28use anyhow::{Context, bail, ensure};
29use hyper::server::conn::http1;
30use tokio::net::TcpListener;
31use tracing::{debug, error, info, warn};
32use wasmtime::component::InstancePre;
33use wasmtime::{AsContextMut, StoreContextMut};
34use wasmtime_wasi_http::{
35    WasiHttpView,
36    bindings::{ProxyPre, http::types::Scheme},
37    body::HyperOutgoingBody,
38    io::TokioIo,
39};
40
41use rustls::{ServerConfig, pki_types::CertificateDer};
42use rustls_pemfile::{certs, private_key};
43use tokio::sync::{RwLock, mpsc};
44use tokio_rustls::TlsAcceptor;
45
46const HTTP_SERVER_ID: &str = "http-server";
47
48#[derive(Clone, Debug)]
49struct HttpWorkloadConfig {
50    host_header: String,
51}
52
53/// A map from host header to resolved workload handles and their associated component id
54pub type WorkloadHandles =
55    Arc<RwLock<HashMap<String, (ResolvedWorkload, InstancePre<Ctx>, String)>>>;
56
57/// HTTP server plugin that handles incoming HTTP requests for WebAssembly components.
58///
59/// This plugin implements the `wasi:http/incoming-handler` interface and routes
60/// HTTP requests to appropriate WebAssembly components based on virtual hosting.
61/// It supports both HTTP and HTTPS connections with optional mutual TLS.
62pub struct HttpServer {
63    addr: SocketAddr,
64    /// Map from host header to resolved workload handles
65    workload_handles: WorkloadHandles,
66    /// Map from workload ID to HTTP-specific config
67    workload_configs: Arc<RwLock<HashMap<String, HttpWorkloadConfig>>>,
68    shutdown_tx: Arc<RwLock<Option<mpsc::Sender<()>>>>,
69    tls_acceptor: Option<TlsAcceptor>,
70}
71
72impl HttpServer {
73    /// Creates a new HTTP server listening on the specified address.
74    ///
75    /// # Arguments
76    /// * `addr` - The socket address to bind to
77    ///
78    /// # Returns
79    /// A new `HttpServer` instance configured for HTTP connections.
80    pub fn new(addr: SocketAddr) -> Self {
81        Self {
82            addr,
83            workload_handles: Arc::default(),
84            workload_configs: Arc::default(),
85            shutdown_tx: Arc::new(RwLock::new(None)),
86            tls_acceptor: None,
87        }
88    }
89
90    /// Creates a new HTTPS server with TLS support.
91    ///
92    /// # Arguments
93    /// * `addr` - The socket address to bind to
94    /// * `cert_path` - Path to the TLS certificate file
95    /// * `key_path` - Path to the private key file
96    /// * `ca_path` - Optional path to CA certificate for mutual TLS
97    ///
98    /// # Returns
99    /// A new `HttpServer` instance configured for HTTPS connections.
100    ///
101    /// # Errors
102    /// Returns an error if the TLS configuration cannot be loaded.
103    pub async fn new_with_tls(
104        addr: SocketAddr,
105        cert_path: &Path,
106        key_path: &Path,
107        ca_path: Option<&Path>,
108    ) -> anyhow::Result<Self> {
109        let tls_config = load_tls_config(cert_path, key_path, ca_path).await?;
110        let tls_acceptor = TlsAcceptor::from(Arc::new(tls_config));
111
112        Ok(Self {
113            addr,
114            workload_handles: Arc::default(),
115            workload_configs: Arc::default(),
116            shutdown_tx: Arc::new(RwLock::new(None)),
117            tls_acceptor: Some(tls_acceptor),
118        })
119    }
120}
121
122#[async_trait::async_trait]
123impl HostPlugin for HttpServer {
124    fn id(&self) -> &'static str {
125        HTTP_SERVER_ID
126    }
127
128    fn world(&self) -> WitWorld {
129        WitWorld {
130            imports: HashSet::from([WitInterface::from(
131                "wasi:http/incoming-handler,outgoing-handler",
132            )]),
133            ..Default::default()
134        }
135    }
136
137    async fn start(&self) -> anyhow::Result<()> {
138        let addr = self.addr;
139        let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
140        let shutdown_tx_clone = self.shutdown_tx.clone();
141        let workload_handles = self.workload_handles.clone();
142        let tls_acceptor = self.tls_acceptor.clone();
143
144        // Store the shutdown sender
145        *shutdown_tx_clone.write().await = Some(shutdown_tx);
146
147        let listener = TcpListener::bind(addr).await?;
148        debug!(addr = ?addr, "HTTP server listening");
149        // Start the HTTP server, any incoming requests call Host::handle and then it's routed
150        // to the workload based on host header.
151        tokio::spawn(async move {
152            if let Err(e) =
153                run_http_server(listener, workload_handles, &mut shutdown_rx, tls_acceptor).await
154            {
155                error!(err = ?e, addr = ?addr, "HTTP server error");
156            }
157        });
158
159        let protocol = if self.tls_acceptor.is_some() {
160            "HTTPS"
161        } else {
162            "HTTP"
163        };
164        debug!(addr = ?addr, protocol = protocol, "HTTP server starting");
165        Ok(())
166    }
167
168    async fn on_component_bind(
169        &self,
170        component: &mut WorkloadComponent,
171        interfaces: std::collections::HashSet<crate::wit::WitInterface>,
172    ) -> anyhow::Result<()> {
173        let Some(http_iface) = interfaces.iter().find(|iface| {
174            iface.namespace == "wasi"
175                && iface.package == "http"
176                && iface.interfaces.contains("incoming-handler")
177        }) else {
178            bail!(
179                "No wasi:http/incoming-handler interface found, plugin should not be bound to this workload"
180            );
181        };
182
183        // Warnings for extra specified interfaces
184        if interfaces.len() > 1 {
185            warn!(
186                interfaces = ?interfaces,
187                "ignoring non-wasi:http/incoming-handler interfaces",
188            );
189        } else if http_iface.interfaces.len() > 1 {
190            warn!(
191                interfaces = ?http_iface.interfaces,
192                "ignoring non-incoming-handler interfaces",
193            );
194        }
195
196        // Use wildcard "*" as default if no host header is specified
197        let host_header = http_iface
198            .config
199            .get("host")
200            .cloned()
201            .unwrap_or_else(|| "*".to_string());
202
203        let id = component.id();
204
205        debug!(host = %host_header, workload_id = id, "binding HTTP config for workload");
206
207        // Store config by workload ID for later retrieval
208        let config = HttpWorkloadConfig { host_header };
209        self.workload_configs
210            .write()
211            .await
212            .insert(id.to_string(), config);
213
214        // NOTE: There is no `add_to_linker` call here because it's already added when initializing
215        // the Ctx, as long as the `http` feature is enabled. This is totally possible to do here, but it would
216        // mean re-implementing wasmtime_wasi_http.
217
218        Ok(())
219    }
220
221    async fn on_workload_resolved(
222        &self,
223        resolved_handle: &ResolvedWorkload,
224        component_id: &str,
225    ) -> anyhow::Result<()> {
226        // Retrieve config using the same ID from bind_workload
227        let config = self
228            .workload_configs
229            .read()
230            .await
231            .get(component_id)
232            .cloned()
233            .ok_or_else(|| anyhow::anyhow!("No HTTP config found for workload {component_id}"))?;
234
235        debug!(host = %config.host_header, workload_id = resolved_handle.id(), component_id, "storing resolved workload handle");
236
237        // Get the pre-instantiated component
238        let instance_pre = resolved_handle.instantiate_pre(component_id).await?;
239
240        // Store the resolved handle with the configured host header
241        self.workload_handles.write().await.insert(
242            config.host_header,
243            (
244                resolved_handle.clone(),
245                instance_pre,
246                component_id.to_string(),
247            ),
248        );
249
250        Ok(())
251    }
252
253    async fn on_workload_unbind(
254        &self,
255        workload: &ResolvedWorkload,
256        _interfaces: HashSet<WitInterface>,
257    ) -> anyhow::Result<()> {
258        debug!(workload_id = workload.id(), "removing HTTP workload handle");
259
260        // Remove from workload handles
261        let mut handles_guard = self.workload_handles.write().await;
262        handles_guard.retain(|_, (handle, _, _)| handle.id() != workload.id());
263
264        // Remove from workload configs
265        self.workload_configs.write().await.remove(workload.id());
266
267        Ok(())
268    }
269
270    async fn stop(&self) -> anyhow::Result<()> {
271        info!(addr = ?self.addr, "HTTP server stopping");
272        // Stop the HTTP server
273        let mut shutdown_guard = self.shutdown_tx.write().await;
274        if let Some(tx) = shutdown_guard.take() {
275            let _ = tx.send(()).await;
276        }
277        Ok(())
278    }
279}
280
281/// HTTP server implementation that routes to workload components
282async fn run_http_server(
283    listener: TcpListener,
284    workload_handles: WorkloadHandles,
285    shutdown_rx: &mut mpsc::Receiver<()>,
286    tls_acceptor: Option<TlsAcceptor>,
287) -> anyhow::Result<()> {
288    loop {
289        tokio::select! {
290            // Handle shutdown signal
291            _ = shutdown_rx.recv() => {
292                info!("HTTP server received shutdown signal");
293                break;
294            }
295            // Accept new connections
296            result = listener.accept() => {
297                match result {
298                    Ok((client, client_addr)) => {
299                        debug!(addr = ?client_addr, "new HTTP client connection");
300
301                        let handles_clone = workload_handles.clone();
302                        let tls_acceptor_clone = tls_acceptor.clone();
303                        tokio::spawn(async move {
304                            let service = hyper::service::service_fn(move |req| {
305                                let handles = handles_clone.clone();
306                                async move {
307                                    handle_http_request(req, handles).await
308                                }
309                            });
310
311                            let result = if let Some(acceptor) = tls_acceptor_clone {
312                                // Handle HTTPS connection
313                                match acceptor.accept(client).await {
314                                    Ok(tls_stream) => {
315                                        http1::Builder::new()
316                                            .keep_alive(true)
317                                            .serve_connection(TokioIo::new(tls_stream), service)
318                                            .await
319                                    }
320                                    Err(e) => {
321                                        error!(addr = ?client_addr, err = ?e, "TLS handshake failed");
322                                        return;
323                                    }
324                                }
325                            } else {
326                                // Handle HTTP connection
327                                http1::Builder::new()
328                                    .keep_alive(true)
329                                    .serve_connection(TokioIo::new(client), service)
330                                    .await
331                            };
332
333                            if let Err(e) = result {
334                                error!(addr = ?client_addr, err = ?e, "error serving HTTP client");
335                            }
336                        });
337                    }
338                    Err(e) => {
339                        error!(err = ?e, "failed to accept HTTP connection");
340                    }
341                }
342            }
343        }
344    }
345
346    Ok(())
347}
348
349/// Handle individual HTTP requests by looking up workload and invoking component
350async fn handle_http_request(
351    req: hyper::Request<hyper::body::Incoming>,
352    workload_handles: WorkloadHandles,
353) -> Result<hyper::Response<HyperOutgoingBody>, hyper::Error> {
354    let method = req.method().clone();
355    let uri = req.uri().clone();
356
357    // Extract the Host header
358    let host_header = req
359        .headers()
360        .get("host")
361        .and_then(|h| h.to_str().ok())
362        .unwrap_or("<no host header>")
363        .to_string(); // Convert to String to avoid borrow issues
364
365    debug!(
366        method = %method,
367        uri = %uri,
368        host = %host_header,
369        "HTTP request received"
370    );
371
372    // Look up workload handle for this host, with wildcard fallback
373    let workload_handle = {
374        let handles = workload_handles.read().await;
375        debug!(host = %host_header, "looking up workload handle for host header");
376
377        // First try exact host match
378        if let Some(handle) = handles.get(&host_header) {
379            Some(handle.clone())
380        } else {
381            // Fall back to wildcard if no exact match
382            debug!("No exact match for host header, trying wildcard '*'");
383            handles.get("*").cloned()
384        }
385    };
386
387    let response = match workload_handle {
388        Some((handle, instance_pre, component_id)) => {
389            match invoke_component_handler(handle, instance_pre, &component_id, req).await {
390                Ok(resp) => resp,
391                Err(e) => {
392                    error!(err = ?e, host = %host_header, "failed to invoke component");
393                    // TODO: add in error
394                    hyper::Response::builder()
395                        .status(500)
396                        .body(HyperOutgoingBody::default())
397                        .unwrap()
398                }
399            }
400        }
401        None => {
402            warn!(host = %host_header, "No workload bound to host header or wildcard '*'");
403            hyper::Response::builder()
404                .status(404)
405                .body(HyperOutgoingBody::default())
406                .unwrap()
407        }
408    };
409
410    Ok(response)
411}
412
413/// Invoke the component handler for the given workload
414async fn invoke_component_handler(
415    workload_handle: ResolvedWorkload,
416    instance_pre: InstancePre<Ctx>,
417    component_id: &str,
418    req: hyper::Request<hyper::body::Incoming>,
419) -> anyhow::Result<hyper::Response<HyperOutgoingBody>> {
420    // Create a new store for this request with plugin contexts
421    let mut store = workload_handle.new_store(component_id).await?;
422
423    handle_component_request(store.as_context_mut(), instance_pre, req).await
424}
425
426/// Handle a component request using WASI HTTP (copied from wash/crates/src/cli/dev.rs)
427pub async fn handle_component_request<'a>(
428    mut store: StoreContextMut<'a, Ctx>,
429    pre: InstancePre<Ctx>,
430    req: hyper::Request<hyper::body::Incoming>,
431) -> anyhow::Result<hyper::Response<HyperOutgoingBody>> {
432    let (sender, receiver) = tokio::sync::oneshot::channel();
433    // TODO: scheme change based on TLS
434    let req = store.data_mut().new_incoming_request(Scheme::Http, req)?;
435    let out = store.data_mut().new_response_outparam(sender)?;
436    let pre = ProxyPre::new(pre).context("failed to instantiate proxy pre")?;
437
438    // Run the http request itself by instantiating and calling the component
439    let proxy = pre.instantiate_async(&mut store).await?;
440
441    proxy
442        .wasi_http_incoming_handler()
443        .call_handle(&mut store, req, out)
444        .await?;
445
446    match receiver.await {
447        // If the client calls `response-outparam::set` then one of these
448        // methods will be called.
449        Ok(Ok(resp)) => Ok(resp),
450        Ok(Err(e)) => Err(e.into()),
451
452        // Otherwise the `sender` will get dropped along with the `Store`
453        // meaning that the oneshot will get disconnected
454        Err(e) => {
455            error!(err = ?e, "error receiving http response");
456            Err(anyhow::anyhow!(
457                "oneshot channel closed but no response was sent"
458            ))
459        }
460    }
461}
462
463/// Load TLS configuration from certificate and key files
464/// Extracted from wash dev command for reuse in HTTP server plugin
465async fn load_tls_config(
466    cert_path: &Path,
467    key_path: &Path,
468    ca_path: Option<&Path>,
469) -> anyhow::Result<ServerConfig> {
470    // Load certificate chain
471    let cert_data = tokio::fs::read(cert_path)
472        .await
473        .with_context(|| format!("Failed to read certificate file: {}", cert_path.display()))?;
474    let mut cert_reader = std::io::Cursor::new(cert_data);
475    let cert_chain: Vec<CertificateDer<'static>> = certs(&mut cert_reader)
476        .collect::<Result<Vec<_>, _>>()
477        .with_context(|| format!("Failed to parse certificate file: {}", cert_path.display()))?;
478
479    ensure!(
480        !cert_chain.is_empty(),
481        "No certificates found in file: {}",
482        cert_path.display()
483    );
484
485    // Load private key
486    let key_data = tokio::fs::read(key_path)
487        .await
488        .with_context(|| format!("Failed to read private key file: {}", key_path.display()))?;
489    let mut key_reader = std::io::Cursor::new(key_data);
490    let key = private_key(&mut key_reader)
491        .with_context(|| format!("Failed to parse private key file: {}", key_path.display()))?
492        .ok_or_else(|| anyhow::anyhow!("No private key found in file: {}", key_path.display()))?;
493
494    // Create rustls server config
495    let config = ServerConfig::builder()
496        .with_no_client_auth()
497        .with_single_cert(cert_chain, key)
498        .with_context(|| "Failed to create TLS configuration")?;
499
500    // If CA is provided, configure client certificate verification
501    if let Some(ca_path) = ca_path {
502        let ca_data = tokio::fs::read(ca_path)
503            .await
504            .with_context(|| format!("Failed to read CA file: {}", ca_path.display()))?;
505        let mut ca_reader = std::io::Cursor::new(ca_data);
506        let ca_certs: Vec<CertificateDer<'static>> = certs(&mut ca_reader)
507            .collect::<Result<Vec<_>, _>>()
508            .with_context(|| format!("Failed to parse CA file: {}", ca_path.display()))?;
509
510        ensure!(
511            !ca_certs.is_empty(),
512            "No CA certificates found in file: {}",
513            ca_path.display()
514        );
515
516        // Note: Client certificate verification configuration would go here
517        // For now, we'll keep it simple without client cert verification
518        debug!("CA certificate loaded, but client certificate verification not yet implemented");
519    }
520
521    Ok(config)
522}