diff --git a/Cargo.lock b/Cargo.lock index fd3edbe620..6170ffd294 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4187,16 +4187,18 @@ name = "outbound-http" version = "2.2.0-pre0" dependencies = [ "anyhow", - "http 0.2.11", + "http 1.0.0", "reqwest", "spin-app", "spin-core", + "spin-http", "spin-locked-app", "spin-outbound-networking", "spin-world", "terminal", "tracing", "url", + "wasmtime-wasi-http", ] [[package]] diff --git a/crates/outbound-http/Cargo.toml b/crates/outbound-http/Cargo.toml index 81f8f86aea..0b55a6432b 100644 --- a/crates/outbound-http/Cargo.toml +++ b/crates/outbound-http/Cargo.toml @@ -9,17 +9,19 @@ doctest = false [dependencies] anyhow = "1.0" -http = "0.2" +http = "1.0.0" reqwest = { version = "0.11", features = ["gzip"] } spin-app = { path = "../app", optional = true } spin-core = { path = "../core", optional = true } +spin-http = { path = "../http", optional = true } spin-locked-app = { path = "../locked-app" } spin-outbound-networking = { path = "../outbound-networking" } spin-world = { path = "../world", optional = true } terminal = { path = "../terminal" } tracing = { workspace = true } url = "2.2.1" +wasmtime-wasi-http = { workspace = true } [features] default = ["runtime"] -runtime = ["dep:spin-app", "dep:spin-core", "dep:spin-world"] +runtime = ["dep:spin-app", "dep:spin-core", "dep:spin-http", "dep:spin-world"] diff --git a/crates/outbound-http/src/host_impl.rs b/crates/outbound-http/src/host_impl.rs index 52314f26d6..ff56155647 100644 --- a/crates/outbound-http/src/host_impl.rs +++ b/crates/outbound-http/src/host_impl.rs @@ -1,5 +1,4 @@ use anyhow::Result; -use http::HeaderMap; use reqwest::Client; use spin_core::async_trait; use spin_outbound_networking::{AllowedHostsConfig, OutboundUrl}; @@ -13,12 +12,71 @@ use spin_world::v1::{ pub struct OutboundHttp { /// List of hosts guest modules are allowed to make requests to. pub allowed_hosts: AllowedHostsConfig, - /// During an incoming HTTP request, origin is set to the host of that incoming HTTP request. - /// This is used to direct outbound requests to the same host when allowed. - pub origin: String, + /// Used to dispatch outbound `self` requests directly to a component. + pub self_dispatcher: HttpSelfDispatcher, client: Option, } +#[derive(Default, Clone)] +pub enum HttpSelfDispatcher { + #[default] + NotHttp, + Handler(std::sync::Arc>), +} + +#[async_trait] +pub trait HttpRequestHandler { + async fn handle( + &self, + mut req: http::Request, + scheme: http::uri::Scheme, + addr: std::net::SocketAddr, + ) -> anyhow::Result>; +} + +impl HttpSelfDispatcher { + pub fn new(handler: &std::sync::Arc>) -> Self { + Self::Handler(handler.clone()) + } + + async fn dispatch(&self, request: Request) -> Result { + match self { + Self::NotHttp => { + tracing::error!("Cannot send request to {}: same-application requests are supported only for applications with HTTP triggers", request.uri); + Err(HttpError::RuntimeError) + } + Self::Handler(handler) => { + let mut reqbuilder = http::Request::builder() + .uri(request.uri) + .method(http_method_from(request.method)); + for (hname, hval) in request.headers { + reqbuilder = reqbuilder.header(hname, hval); + } + let req = reqbuilder + .body(match request.body { + Some(b) => spin_http::body::full(b.into()), + None => spin_http::body::empty(), + }) + .map_err(|_| HttpError::RuntimeError)?; + let scheme = http::uri::Scheme::HTTPS; + let addr = std::net::SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), + 0, + ); + let resp = handler + .handle(req, scheme, addr) + .await + .map_err(|_| HttpError::RuntimeError)?; + Ok(Response { + status: resp.status().as_u16(), + headers: None, + body: None, + }) + } + } + } +} + impl OutboundHttp { /// Check if guest module is allowed to send request to URL, based on the list of /// allowed hosts defined by the runtime. If the url passed in is a relative path, @@ -53,13 +111,13 @@ impl outbound_http::Host for OutboundHttp { return Err(HttpError::DestinationNotAllowed); } - let method = method_from(req.method); + if req.uri.starts_with('/') { + return self.self_dispatcher.dispatch(req).await; + } + + let method = reqwest_method_from(req.method); - let abs_url = if req.uri.starts_with('/') { - format!("{}{}", self.origin, req.uri) - } else { - req.uri.clone() - }; + let abs_url = req.uri.clone(); let req_url = reqwest::Url::parse(&abs_url).map_err(|_| HttpError::InvalidUrl)?; @@ -111,7 +169,7 @@ fn log_reqwest_error(err: reqwest::Error) -> HttpError { HttpError::RuntimeError } -fn method_from(m: Method) -> http::Method { +fn http_method_from(m: Method) -> http::Method { match m { Method::Get => http::Method::GET, Method::Post => http::Method::POST, @@ -123,6 +181,18 @@ fn method_from(m: Method) -> http::Method { } } +fn reqwest_method_from(m: Method) -> reqwest::Method { + match m { + Method::Get => reqwest::Method::GET, + Method::Post => reqwest::Method::POST, + Method::Put => reqwest::Method::PUT, + Method::Delete => reqwest::Method::DELETE, + Method::Patch => reqwest::Method::PATCH, + Method::Head => reqwest::Method::HEAD, + Method::Options => reqwest::Method::OPTIONS, + } +} + async fn response_from_reqwest(res: reqwest::Response) -> Result { let status = res.status().as_u16(); let headers = response_headers(res.headers()).map_err(|_| HttpError::RuntimeError)?; @@ -141,18 +211,20 @@ async fn response_from_reqwest(res: reqwest::Response) -> Result anyhow::Result { - let mut res = HeaderMap::new(); +fn request_headers(h: Headers) -> anyhow::Result { + let mut res = reqwest::header::HeaderMap::new(); for (k, v) in h { res.insert( - http::header::HeaderName::try_from(k)?, - http::header::HeaderValue::try_from(v)?, + reqwest::header::HeaderName::try_from(k)?, + reqwest::header::HeaderValue::try_from(v)?, ); } Ok(res) } -fn response_headers(h: &HeaderMap) -> anyhow::Result>> { +fn response_headers( + h: &reqwest::header::HeaderMap, +) -> anyhow::Result>> { let mut res: Vec<(String, String)> = vec![]; for (k, v) in h { diff --git a/crates/outbound-http/src/lib.rs b/crates/outbound-http/src/lib.rs index 33a726f34b..c935e60e1c 100644 --- a/crates/outbound-http/src/lib.rs +++ b/crates/outbound-http/src/lib.rs @@ -5,6 +5,8 @@ mod host_impl; #[cfg(feature = "runtime")] pub use host_component::OutboundHttpComponent; +#[cfg(feature = "runtime")] +pub use host_impl::{HttpRequestHandler, HttpSelfDispatcher}; use spin_locked_app::MetadataKey; diff --git a/crates/trigger-http/src/handler.rs b/crates/trigger-http/src/handler.rs index 08fc6ce98d..bb1ed5aefc 100644 --- a/crates/trigger-http/src/handler.rs +++ b/crates/trigger-http/src/handler.rs @@ -7,7 +7,6 @@ use futures::TryFutureExt; use http::{HeaderName, HeaderValue}; use http_body_util::BodyExt; use hyper::{Request, Response}; -use outbound_http::OutboundHttpComponent; use spin_core::async_trait; use spin_core::wasi_2023_10_18::exports::wasi::http::incoming_handler::IncomingHandler as IncomingHandler2023_10_18; use spin_core::wasi_2023_11_10::exports::wasi::http::incoming_handler::IncomingHandler as IncomingHandler2023_11_10; @@ -20,7 +19,15 @@ use tokio::{sync::oneshot, task}; use wasmtime_wasi_http::{proxy::Proxy, WasiHttpView}; #[derive(Clone)] -pub struct HttpHandlerExecutor; +pub struct HttpHandlerExecutor { + trigger: HttpTrigger, +} + +impl HttpHandlerExecutor { + pub fn new(trigger: HttpTrigger) -> Self { + Self { trigger } + } +} #[async_trait] impl HttpExecutor for HttpHandlerExecutor { @@ -43,7 +50,7 @@ impl HttpExecutor for HttpHandlerExecutor { unreachable!() }; - set_http_origin_from_request(&mut store, engine, &req); + self.set_self_dispatcher(&mut store, engine); let resp = match HandlerType::from_exports(instance.exports(&mut store)) { Some(HandlerType::Wasi) => { @@ -69,6 +76,18 @@ impl HttpExecutor for HttpHandlerExecutor { } } +#[async_trait] +impl outbound_http::HttpRequestHandler for HttpTrigger { + async fn handle( + &self, + req: http::Request, + scheme: http::uri::Scheme, + addr: std::net::SocketAddr, + ) -> anyhow::Result> { + self.handle(req, scheme, addr).await + } +} + impl HttpHandlerExecutor { pub async fn execute_spin( mut store: Store, @@ -317,6 +336,31 @@ impl HttpHandlerExecutor { Ok(()) } + + fn set_self_dispatcher(&self, store: &mut Store, engine: &TriggerAppEngine) { + if let Some(outbound_http_handle) = engine + .engine + .find_host_component_handle::>() + { + let outbound_http_data = store + .host_components_data() + .get_or_insert(outbound_http_handle); + let allowed_hosts = outbound_http_data.allowed_hosts.clone(); + + // The reason this uses a Box and the WASI one uses an Arc is that we don't want + // the `outbound_http` crate to depend on `http-trigger` so we have to put it through + // a trait. But TODO: try to unify these. + let http_handler = Box::new(self.trigger.clone()); + outbound_http_data.self_dispatcher = + outbound_http::HttpSelfDispatcher::new(&Arc::new(http_handler)); + + let arc_http_handler = Arc::new(self.trigger.clone()); + store.as_mut().data_mut().as_mut().self_dispatcher = + crate::WasiHttpSelfDispatcher::new(arc_http_handler); + store.as_mut().data_mut().as_mut().allowed_hosts = + allowed_hosts; + } + } } /// Whether this handler uses the custom Spin http handler interface for wasi-http @@ -345,31 +389,6 @@ impl HandlerType { } } -fn set_http_origin_from_request( - store: &mut Store, - engine: &TriggerAppEngine, - req: &Request, -) { - if let Some(authority) = req.uri().authority() { - if let Some(scheme) = req.uri().scheme_str() { - let origin = format!("{}://{}", scheme, authority); - if let Some(outbound_http_handle) = engine - .engine - .find_host_component_handle::>() - { - let outbound_http_data = store - .host_components_data() - .get_or_insert(outbound_http_handle); - - outbound_http_data.origin = origin.clone(); - store.as_mut().data_mut().as_mut().allowed_hosts = - outbound_http_data.allowed_hosts.clone(); - } - store.as_mut().data_mut().as_mut().origin = Some(origin); - } - } -} - fn contextualise_err(e: anyhow::Error) -> anyhow::Error { if e.to_string() .contains("failed to find function export `canonical_abi_free`") diff --git a/crates/trigger-http/src/lib.rs b/crates/trigger-http/src/lib.rs index ddabf57afe..0633d01660 100644 --- a/crates/trigger-http/src/lib.rs +++ b/crates/trigger-http/src/lib.rs @@ -11,7 +11,7 @@ use std::{ sync::Arc, }; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use async_trait::async_trait; use clap::Args; use http::{uri::Scheme, StatusCode, Uri}; @@ -48,9 +48,10 @@ pub use tls::TlsConfig; pub(crate) type RuntimeData = HttpRuntimeData; pub(crate) type Store = spin_core::Store; +#[derive(Clone)] /// The Spin HTTP trigger. pub struct HttpTrigger { - engine: TriggerAppEngine, + engine: std::sync::Arc>, router: Router, // Base path for component routes. base: String, @@ -132,7 +133,7 @@ impl TriggerExecutor for HttpTrigger { .collect(); Ok(Self { - engine, + engine: std::sync::Arc::new(engine), router, base, component_trigger_configs, @@ -220,7 +221,7 @@ impl HttpTrigger { let res = match executor { HttpExecutorType::Http => { - HttpHandlerExecutor + HttpHandlerExecutor::new(self.clone()) .execute( &self.engine, component_id, @@ -452,22 +453,102 @@ pub(crate) trait HttpExecutor: Clone + Send + Sync + 'static { #[derive(Default)] pub struct HttpRuntimeData { - origin: Option, + self_dispatcher: WasiHttpSelfDispatcher, /// The hosts this app is allowed to make outbound requests to allowed_hosts: AllowedHostsConfig, } +#[derive(Clone, Default)] +enum WasiHttpSelfDispatcher { + #[default] + NotHttp, + Handler(Arc), +} + +type OutboundHandle = wasmtime_wasi::preview2::AbortOnDropJoinHandle< + Result< + Result< + wasmtime_wasi_http::types::IncomingResponseInternal, + wasmtime_wasi_http::bindings::http::types::ErrorCode, + >, + anyhow::Error, + >, +>; + +impl WasiHttpSelfDispatcher { + fn new(trigger: Arc) -> Self { + Self::Handler(trigger) + } + + fn dispatch( + &self, + data: &mut spin_core::Data, + request: wasmtime_wasi_http::types::OutgoingRequest, + ) -> wasmtime::Result< + wasmtime::component::Resource, + > { + use wasmtime_wasi_http::types::WasiHttpView; + match self { + Self::NotHttp => { + let message = format!("Cannot send request to {}: same-application requests are supported only for applications with HTTP triggers", request.request.uri()); + tracing::error!(message); + Err(anyhow::Error::msg(message)) + } + Self::Handler(t) => { + let trigger = t.clone(); + + let between_bytes_timeout = request.between_bytes_timeout; + let req = request.request; + let scheme = req + .uri() + .scheme() + .cloned() + .unwrap_or(http::uri::Scheme::HTTPS); + let addr = std::net::SocketAddr::new( + std::net::IpAddr::V4(std::net::Ipv4Addr::UNSPECIFIED), + 0, + ); + + let resp_fut = wasmtime_wasi::preview2::spawn(async move { + match trigger.handle(req, scheme, addr).await { + Err(e) => { + let error = anyhow!("Inline call to HTTP component failed: {e:?}"); + tracing::error!("{:?}", error); + Err(error) + } + Ok(resp) => { + let worker = Arc::new(wasmtime_wasi::preview2::spawn(async {})); // TODO: better? + let incoming_resp = + wasmtime_wasi_http::types::IncomingResponseInternal { + resp, + worker, + between_bytes_timeout, + }; + Ok(Ok(incoming_resp)) + } + } + }); + + let outbound_handle = OutboundHandle::from(resp_fut); + let future_resp = + wasmtime_wasi_http::types::HostFutureIncomingResponse::new(outbound_handle); + let resp_resource = data.table().push(future_resp)?; + Ok(resp_resource) + } + } + } +} + impl OutboundWasiHttpHandler for HttpRuntimeData { fn send_request( data: &mut spin_core::Data, - mut request: wasmtime_wasi_http::types::OutgoingRequest, + request: wasmtime_wasi_http::types::OutgoingRequest, ) -> wasmtime::Result< wasmtime::component::Resource, > where Self: Sized, { - let this = data.as_ref(); let is_relative_url = request .request .uri() @@ -475,28 +556,31 @@ impl OutboundWasiHttpHandler for HttpRuntimeData { .map(|a| a.host().trim() == "") .unwrap_or_default(); if is_relative_url { - // Origin must be set in the incoming http handler - let origin = this.origin.clone().unwrap(); - let path_and_query = request - .request - .uri() - .path_and_query() - .map(|p| p.as_str()) - .unwrap_or("/"); - let uri: Uri = format!("{origin}{path_and_query}") - .parse() - // origin together with the path and query must be a valid URI - .unwrap(); - - request.use_tls = uri - .scheme() - .map(|s| s == &Scheme::HTTPS) - .unwrap_or_default(); - // We know that `uri` has an authority because we set it above - request.authority = uri.authority().unwrap().as_str().to_owned(); - *request.request.uri_mut() = uri; + let self_dispatcher = data.as_ref().self_dispatcher.clone(); + return self_dispatcher.dispatch(data, request); + // // Origin must be set in the incoming http handler + // let origin = this.origin.clone().unwrap(); + // let path_and_query = request + // .request + // .uri() + // .path_and_query() + // .map(|p| p.as_str()) + // .unwrap_or("/"); + // let uri: Uri = format!("{origin}{path_and_query}") + // .parse() + // // origin together with the path and query must be a valid URI + // .unwrap(); + + // request.use_tls = uri + // .scheme() + // .map(|s| s == &Scheme::HTTPS) + // .unwrap_or_default(); + // // We know that `uri` has an authority because we set it above + // request.authority = uri.authority().unwrap().as_str().to_owned(); + // *request.request.uri_mut() = uri; } + let this = data.as_ref(); let uri = request.request.uri(); let uri_string = uri.to_string(); let unallowed_relative = diff --git a/examples/spin-timer/Cargo.lock b/examples/spin-timer/Cargo.lock index c4569eeeb8..898f3110e7 100644 --- a/examples/spin-timer/Cargo.lock +++ b/examples/spin-timer/Cargo.lock @@ -2547,16 +2547,18 @@ name = "outbound-http" version = "2.2.0-pre0" dependencies = [ "anyhow", - "http 0.2.9", + "http 1.0.0", "reqwest", "spin-app", "spin-core", + "spin-http", "spin-locked-app", "spin-outbound-networking", "spin-world", "terminal", "tracing", "url", + "wasmtime-wasi-http", ] [[package]] @@ -3652,6 +3654,23 @@ dependencies = [ "wasmtime-wasi-http", ] +[[package]] +name = "spin-http" +version = "2.2.0-pre0" +dependencies = [ + "anyhow", + "http 1.0.0", + "http-body-util", + "hyper 1.1.0", + "indexmap 1.9.3", + "percent-encoding", + "serde", + "spin-app", + "spin-locked-app", + "tracing", + "wasmtime-wasi-http", +] + [[package]] name = "spin-key-value" version = "2.2.0-pre0"