Skip to main content

spin_factor_outbound_http/
lib.rs

1pub mod intercept;
2pub mod runtime_config;
3mod spin;
4mod wasi;
5pub mod wasi_2023_10_18;
6pub mod wasi_2023_11_10;
7pub mod wasi_2026_03_15;
8
9use std::{net::SocketAddr, sync::Arc};
10
11use anyhow::Context;
12use http::{
13    HeaderValue, Uri,
14    uri::{Authority, Parts, PathAndQuery, Scheme},
15};
16use intercept::OutboundHttpInterceptor;
17use runtime_config::RuntimeConfig;
18use spin_factor_otel::OtelFactorState;
19use spin_factor_outbound_networking::{
20    ComponentTlsClientConfigs, ConnectionSemaphore, OutboundNetworkingFactor,
21    build_connection_semaphore,
22    config::{allowed_hosts::OutboundAllowedHosts, blocked_networks::BlockedNetworks},
23};
24use spin_factors::{
25    ConfigureAppContext, Factor, FactorData, PrepareContext, RuntimeFactors, SelfInstanceBuilder,
26    anyhow,
27};
28use wasmtime_wasi_http::WasiHttpCtx;
29
30pub use wasmtime_wasi_http::p2::{
31    HttpResult, bindings::http::types::ErrorCode, body::HyperOutgoingBody,
32    types::HostFutureIncomingResponse,
33};
34
35#[derive(Default)]
36pub struct OutboundHttpFactor {
37    _priv: (),
38}
39
40impl Factor for OutboundHttpFactor {
41    type RuntimeConfig = RuntimeConfig;
42    type AppState = AppState;
43    type InstanceBuilder = InstanceState;
44
45    fn init<T: spin_factors::InitContext<Self>>(&mut self, ctx: &mut T) -> anyhow::Result<()> {
46        ctx.link_bindings(spin_world::v1::http::add_to_linker::<_, FactorData<Self>>)?;
47        wasi::add_to_linker(ctx)?;
48        Ok(())
49    }
50
51    fn configure_app<T: RuntimeFactors>(
52        &self,
53        mut ctx: ConfigureAppContext<T, Self>,
54    ) -> anyhow::Result<Self::AppState> {
55        let config = ctx.take_runtime_config().unwrap_or_default();
56        let networking = ctx.app_state::<OutboundNetworkingFactor>().ok();
57
58        Ok(AppState {
59            wasi_http_clients: wasi::HttpClients::new(config.connection_pooling_enabled),
60            connection_pooling_enabled: config.connection_pooling_enabled,
61            semaphore: build_connection_semaphore(
62                networking,
63                "http",
64                config.max_concurrent_connections,
65                config.wait_timeout,
66            ),
67        })
68    }
69
70    fn prepare<T: RuntimeFactors>(
71        &self,
72        mut ctx: PrepareContext<T, Self>,
73    ) -> anyhow::Result<Self::InstanceBuilder> {
74        let outbound_networking = ctx.instance_builder::<OutboundNetworkingFactor>()?;
75        let allowed_hosts = outbound_networking.allowed_hosts();
76        let blocked_networks = outbound_networking.blocked_networks();
77        let component_tls_configs = outbound_networking.component_tls_configs();
78        let otel = OtelFactorState::from_prepare_context(&mut ctx)?;
79        Ok(InstanceState {
80            wasi_http_ctx: WasiHttpCtx::new(),
81            hooks: InstanceHttpHooks {
82                allowed_hosts,
83                blocked_networks,
84                component_tls_configs,
85                self_request_origin: None,
86                request_interceptor: None,
87                spin_http_client: None,
88                wasi_http_clients: ctx.app_state().wasi_http_clients.clone(),
89                connection_pooling_enabled: ctx.app_state().connection_pooling_enabled,
90                semaphore: ctx.app_state().semaphore.clone(),
91                otel,
92            },
93        })
94    }
95}
96
97pub struct InstanceState {
98    wasi_http_ctx: WasiHttpCtx,
99    hooks: InstanceHttpHooks,
100}
101
102struct InstanceHttpHooks {
103    allowed_hosts: OutboundAllowedHosts,
104    blocked_networks: BlockedNetworks,
105    component_tls_configs: ComponentTlsClientConfigs,
106    self_request_origin: Option<SelfRequestOrigin>,
107    request_interceptor: Option<Arc<dyn OutboundHttpInterceptor>>,
108    // Connection-pooling client for 'fermyon:spin/http' interface
109    //
110    // TODO: We could move this to `AppState` like the
111    // `wasi:http/outgoing-handler` pool for consistency, although it's probably
112    // not a high priority given that `fermyon:spin/http` is deprecated anyway.
113    spin_http_client: Option<reqwest::Client>,
114    // Connection pooling clients for `wasi:http/outgoing-handler` interface
115    //
116    // This is a clone of `AppState::wasi_http_clients`, meaning it is shared
117    // among all instances of the app.
118    wasi_http_clients: wasi::HttpClients,
119    /// Whether connection pooling is enabled for this instance.
120    connection_pooling_enabled: bool,
121    /// Semaphore to limit concurrent outbound connections.
122    semaphore: ConnectionSemaphore,
123    /// Manages access to the OtelFactor state.
124    otel: OtelFactorState,
125}
126
127impl InstanceState {
128    /// Sets the [`SelfRequestOrigin`] for this instance.
129    ///
130    /// This is used to handle outbound requests to relative URLs. If unset,
131    /// those requests will fail.
132    pub fn set_self_request_origin(&mut self, origin: SelfRequestOrigin) {
133        self.hooks.self_request_origin = Some(origin);
134    }
135
136    /// Sets a [`OutboundHttpInterceptor`] for this instance.
137    ///
138    /// Returns an error if it has already been called for this instance.
139    pub fn set_request_interceptor(
140        &mut self,
141        interceptor: impl OutboundHttpInterceptor + 'static,
142    ) -> anyhow::Result<()> {
143        if self.hooks.request_interceptor.is_some() {
144            anyhow::bail!("set_request_interceptor can only be called once");
145        }
146        self.hooks.request_interceptor = Some(Arc::new(interceptor));
147        Ok(())
148    }
149}
150
151impl SelfInstanceBuilder for InstanceState {}
152
153pub type Request = http::Request<wasmtime_wasi_http::p2::body::HyperOutgoingBody>;
154pub type Response = http::Response<wasmtime_wasi_http::p2::body::HyperIncomingBody>;
155
156/// SelfRequestOrigin indicates the base URI to use for "self" requests.
157#[derive(Clone, Debug)]
158pub struct SelfRequestOrigin {
159    pub scheme: Scheme,
160    pub authority: Authority,
161}
162
163impl SelfRequestOrigin {
164    pub fn create(scheme: Scheme, auth: &str) -> anyhow::Result<Self> {
165        Ok(SelfRequestOrigin {
166            scheme,
167            authority: auth
168                .parse()
169                .with_context(|| format!("address '{auth}' is not a valid authority"))?,
170        })
171    }
172
173    pub fn from_uri(uri: &Uri) -> anyhow::Result<Self> {
174        Ok(Self {
175            scheme: uri.scheme().context("URI missing scheme")?.clone(),
176            authority: uri.authority().context("URI missing authority")?.clone(),
177        })
178    }
179
180    fn into_uri(self, path_and_query: Option<PathAndQuery>) -> Uri {
181        let mut parts = Parts::default();
182        parts.scheme = Some(self.scheme);
183        parts.authority = Some(self.authority);
184        parts.path_and_query = path_and_query;
185        Uri::from_parts(parts).unwrap()
186    }
187
188    fn use_tls(&self) -> bool {
189        self.scheme == Scheme::HTTPS
190    }
191
192    fn host_header(&self) -> HeaderValue {
193        HeaderValue::from_str(self.authority.as_str()).unwrap()
194    }
195}
196
197impl std::fmt::Display for SelfRequestOrigin {
198    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
199        write!(f, "{}://{}", self.scheme, self.authority)
200    }
201}
202
203pub struct AppState {
204    // Connection pooling clients for `wasi:http/outgoing-handler` interface
205    wasi_http_clients: wasi::HttpClients,
206    /// Whether connection pooling is enabled for this app.
207    connection_pooling_enabled: bool,
208    /// Semaphore to limit concurrent outbound connections.
209    semaphore: ConnectionSemaphore,
210}
211
212/// Removes IPs in the given [`BlockedNetworks`].
213///
214/// Returns [`ErrorCode::DestinationIpProhibited`] if all IPs are removed.
215fn remove_blocked_addrs(
216    blocked_networks: &BlockedNetworks,
217    addrs: &mut Vec<SocketAddr>,
218) -> Result<(), ErrorCode> {
219    if addrs.is_empty() {
220        return Ok(());
221    }
222    let blocked_addrs = blocked_networks.remove_blocked(addrs);
223    if addrs.is_empty() && !blocked_addrs.is_empty() {
224        tracing::error!(
225            "error.type" = "destination_ip_prohibited",
226            ?blocked_addrs,
227            "all destination IP(s) prohibited by runtime config"
228        );
229        return Err(ErrorCode::DestinationIpProhibited);
230    }
231    Ok(())
232}