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