Skip to main content

spin_factor_outbound_networking/
lib.rs

1mod allowed_hosts;
2pub mod runtime_config;
3mod tls;
4
5use std::{collections::HashMap, net::SocketAddr, sync::Arc};
6
7use futures_util::FutureExt as _;
8use opentelemetry_semantic_conventions::attribute as otel_attribute;
9use spin_factor_variables::VariablesFactor;
10use spin_factor_wasi::{SocketAddrUse, SocketPermitState, WasiFactor};
11use spin_factors::{
12    ConfigureAppContext, Error, Factor, FactorInstanceBuilder, PrepareContext, RuntimeFactors,
13    anyhow::{self, Context},
14};
15use spin_locked_app::APP_NAME_KEY;
16use spin_outbound_networking_config::allowed_hosts::{DisallowedHostHandler, OutboundAllowedHosts};
17use url::Url;
18
19use crate::{
20    allowed_hosts::allowed_outbound_hosts, runtime_config::RuntimeConfig, tls::TlsClientConfigs,
21};
22pub use allowed_hosts::validate_service_chaining_for_components;
23pub use spin_connection_semaphore::{ConnectionPermit, ConnectionSemaphore, LimitedSemaphore};
24
25pub use crate::tls::{ComponentTlsClientConfigs, TlsClientConfig};
26use config::allowed_hosts::AllowedHostsConfig;
27use config::blocked_networks::BlockedNetworks;
28pub use spin_outbound_networking_config as config;
29
30#[derive(Default)]
31pub struct OutboundNetworkingFactor {
32    disallowed_host_handler: Option<Arc<dyn DisallowedHostHandler>>,
33}
34
35impl OutboundNetworkingFactor {
36    pub fn new() -> Self {
37        Self::default()
38    }
39
40    /// Sets a handler to be called when a request is disallowed by an
41    /// instance's configured `allowed_outbound_hosts`.
42    pub fn set_disallowed_host_handler(&mut self, handler: impl DisallowedHostHandler + 'static) {
43        self.disallowed_host_handler = Some(Arc::new(handler));
44    }
45}
46
47impl Factor for OutboundNetworkingFactor {
48    type RuntimeConfig = RuntimeConfig;
49    type AppState = AppState;
50    type InstanceBuilder = InstanceBuilder;
51
52    fn configure_app<T: RuntimeFactors>(
53        &self,
54        mut ctx: ConfigureAppContext<T, Self>,
55    ) -> anyhow::Result<Self::AppState> {
56        // Extract allowed_outbound_hosts for all components
57        let component_allowed_hosts = ctx
58            .app()
59            .components()
60            .map(|component| {
61                Ok((
62                    component.id().to_string(),
63                    allowed_outbound_hosts(&component)?
64                        .into_boxed_slice()
65                        .into(),
66                ))
67            })
68            .collect::<anyhow::Result<_>>()?;
69
70        let RuntimeConfig {
71            client_tls_configs,
72            blocked_ip_networks: block_networks,
73            block_private_networks,
74            max_socket_connections,
75            max_total_connections,
76            wait_timeout,
77        } = ctx.take_runtime_config().unwrap_or_default();
78
79        let blocked_networks = BlockedNetworks::new(block_networks, block_private_networks);
80        let tls_client_configs = TlsClientConfigs::new(client_tls_configs)?;
81        // Build the shared global semaphore from its limit, so the two are bound together from
82        // creation as the pair is plumbed into per-factor `ConnectionSemaphore`s.
83        let global_connection_semaphore = max_total_connections.map(LimitedSemaphore::new);
84
85        if let (Some(socket_cap), Some(global_cap)) =
86            (max_socket_connections, max_total_connections)
87            && socket_cap > global_cap
88        {
89            tracing::warn!(
90                "outbound_networking max_socket_connections ({socket_cap}) exceeds \
91                 max_total_connections ({global_cap}); the global limit will be the effective \
92                 cap for TCP/UDP sockets"
93            );
94        }
95
96        let app_id: Arc<str> = ctx
97            .app()
98            .get_metadata(APP_NAME_KEY)?
99            .unwrap_or_else(|| "<unnamed>".into())
100            .into();
101
102        let socket_connection_semaphore =
103            if max_socket_connections.is_some() || global_connection_semaphore.is_some() {
104                Some(ConnectionSemaphore::new(
105                    global_connection_semaphore.clone(),
106                    max_socket_connections.map(LimitedSemaphore::new),
107                    "wasi-sockets",
108                    app_id.clone(),
109                    wait_timeout,
110                ))
111            } else {
112                None
113            };
114
115        Ok(AppState {
116            component_allowed_hosts,
117            blocked_networks,
118            tls_client_configs,
119            socket_connection_semaphore,
120            global_connection_semaphore,
121            app_id,
122        })
123    }
124
125    fn prepare<T: RuntimeFactors>(
126        &self,
127        mut ctx: PrepareContext<T, Self>,
128    ) -> anyhow::Result<Self::InstanceBuilder> {
129        let hosts = ctx
130            .app_state()
131            .component_allowed_hosts
132            .get(ctx.app_component().id())
133            .cloned()
134            .context("missing component allowed hosts")?;
135        let is_empty = hosts.is_empty();
136        let resolver = ctx
137            .instance_builder::<VariablesFactor>()?
138            .expression_resolver()
139            .clone();
140        let component_ids = ctx
141            .app_component()
142            .app
143            .components()
144            .map(|c| c.id().to_string())
145            .collect::<Vec<_>>();
146        let allowed_hosts_future = async move {
147            let prepared = resolver.prepare().await.inspect_err(|err| {
148                tracing::error!(
149                    %err, "error.type" = "variable_resolution_failed",
150                    "Error resolving variables when checking request against allowed outbound hosts",
151                );
152            })?;
153            AllowedHostsConfig::parse(&hosts, &prepared, &component_ids).inspect_err(|err| {
154                tracing::error!(
155                    %err, "error.type" = "invalid_allowed_hosts",
156                    "Error parsing allowed outbound hosts",
157                );
158            })
159        }
160        .map(|res| res.map(Arc::new).map_err(Arc::new))
161        .boxed()
162        .shared();
163        let allowed_hosts = OutboundAllowedHosts::new(
164            allowed_hosts_future.clone(),
165            self.disallowed_host_handler.clone(),
166            is_empty,
167        );
168        let blocked_networks = ctx.app_state().blocked_networks.clone();
169        let permit_state = ctx
170            .app_state()
171            .socket_connection_semaphore
172            .clone()
173            .map(SocketPermitState::new);
174
175        match ctx.instance_builder::<WasiFactor>() {
176            Ok(wasi_builder) => {
177                if let Some(state) = permit_state {
178                    wasi_builder.set_socket_permit_state(state);
179                }
180
181                let allowed_hosts = allowed_hosts.clone();
182
183                wasi_builder.allow_tcp_udp(!allowed_hosts.is_empty());
184
185                wasi_builder.outbound_socket_addr_check(move |addr, addr_use| {
186                    socket_addr_use_allowed(
187                        allowed_hosts.clone(),
188                        blocked_networks.clone(),
189                        addr,
190                        addr_use,
191                    )
192                });
193            }
194            Err(Error::NoSuchFactor(_)) => (), // no WasiFactor to configure; that's OK
195            Err(err) => return Err(err.into()),
196        }
197
198        let component_tls_configs = ctx
199            .app_state()
200            .tls_client_configs
201            .get_component_tls_configs(ctx.app_component().id());
202
203        Ok(InstanceBuilder {
204            allowed_hosts,
205            blocked_networks: ctx.app_state().blocked_networks.clone(),
206            component_tls_client_configs: component_tls_configs,
207        })
208    }
209}
210
211pub struct AppState {
212    /// Component ID -> Allowed host list
213    component_allowed_hosts: HashMap<String, Arc<[String]>>,
214    /// Blocked IP networks
215    blocked_networks: BlockedNetworks,
216    /// TLS client configs
217    tls_client_configs: TlsClientConfigs,
218    /// Pre-built semaphore for TCP/UDP socket quota enforcement (global + socket-specific).
219    /// `None` means no limits are configured.
220    socket_connection_semaphore: Option<ConnectionSemaphore>,
221    /// App-wide semaphore capping total concurrent outbound connections across ALL types,
222    /// paired with its configured limit (the latter is used for warning comparisons in other
223    /// factors). `None` means unlimited.
224    global_connection_semaphore: Option<LimitedSemaphore>,
225    /// Identifier of this app, used for tenant attribution on tracing events emitted by
226    /// outbound factors' connection semaphores. Resolved once at configure-app time.
227    app_id: Arc<str>,
228}
229
230impl AppState {
231    /// Returns the app identifier, used by outbound factors when building per-factor
232    /// connection semaphores so rejection tracing events carry tenant attribution.
233    pub fn app_id(&self) -> Arc<str> {
234        self.app_id.clone()
235    }
236}
237
238/// Builds a [`ConnectionSemaphore`] for an outbound factor, incorporating the optional global
239/// connection limit from the networking factor's app state.
240///
241/// Emits a warning when the per-factor limit exceeds the global cap (the global limit would
242/// be the effective ceiling in that case).
243///
244/// The app identifier is taken from the networking app state (or `<unnamed>` when networking is
245/// absent) and plumbed through to the semaphore so that rejection tracing events can carry tenant
246/// attribution without app identity appearing on metric labels.
247pub fn build_connection_semaphore(
248    networking: Option<&AppState>,
249    factor: &'static str,
250    factor_limit: Option<usize>,
251    wait_timeout: Option<std::time::Duration>,
252) -> ConnectionSemaphore {
253    let app_id = networking
254        .map(|n| n.app_id())
255        .unwrap_or_else(|| Arc::from("<unnamed>"));
256    let global = networking.and_then(|n| n.global_connection_semaphore.clone());
257    if let (Some(per_factor), Some(global_limit)) =
258        (factor_limit, global.as_ref().map(LimitedSemaphore::limit))
259        && per_factor > global_limit
260    {
261        tracing::warn!(
262            "outbound_{factor} max_connections ({per_factor}) exceeds global \
263             max_total_connections ({global_limit}); the global limit will be the \
264             effective cap"
265        );
266    }
267    ConnectionSemaphore::new(
268        global,
269        factor_limit.map(LimitedSemaphore::new),
270        factor,
271        app_id,
272        wait_timeout,
273    )
274}
275
276pub struct InstanceBuilder {
277    allowed_hosts: OutboundAllowedHosts,
278    blocked_networks: BlockedNetworks,
279    component_tls_client_configs: ComponentTlsClientConfigs,
280}
281
282impl InstanceBuilder {
283    pub fn allowed_hosts(&self) -> OutboundAllowedHosts {
284        self.allowed_hosts.clone()
285    }
286
287    pub fn blocked_networks(&self) -> BlockedNetworks {
288        self.blocked_networks.clone()
289    }
290
291    pub fn component_tls_configs(&self) -> ComponentTlsClientConfigs {
292        self.component_tls_client_configs.clone()
293    }
294}
295
296impl FactorInstanceBuilder for InstanceBuilder {
297    type InstanceState = Self;
298
299    fn build(self) -> anyhow::Result<Self::InstanceState> {
300        Ok(self)
301    }
302}
303
304/// Records the address host, port, and database as fields on the current tracing span.
305///
306/// This should only be called from within a function that has been instrumented with a span.
307///
308/// The following fields must be pre-declared as empty on the span or they will not show up.
309/// ```
310/// use tracing::field::Empty;
311/// use opentelemetry_semantic_conventions::attribute as otel_attribute;
312/// #[tracing::instrument(fields({otel_attribute::SERVER_ADDRESS} = Empty, {otel_attribute::SERVER_PORT} = Empty, {otel_attribute::DB_NAMESPACE} = Empty))]
313/// fn open() {}
314/// ```
315pub fn record_address_fields(address: &str) {
316    if let Ok(url) = Url::parse(address) {
317        let span = tracing::Span::current();
318        span.record(
319            otel_attribute::SERVER_ADDRESS,
320            url.host_str().unwrap_or_default(),
321        );
322        span.record(otel_attribute::SERVER_PORT, url.port().unwrap_or_default());
323        span.record(
324            otel_attribute::DB_NAMESPACE,
325            url.path().trim_start_matches('/'),
326        );
327    }
328}
329
330async fn socket_addr_use_allowed(
331    allowed_hosts: OutboundAllowedHosts,
332    blocked_networks: BlockedNetworks,
333    addr: SocketAddr,
334    addr_use: SocketAddrUse,
335) -> bool {
336    let scheme = match addr_use {
337        SocketAddrUse::TcpListen | SocketAddrUse::TcpAccept => {
338            return false;
339        }
340        SocketAddrUse::TcpBind => {
341            // As of Wasmtime 48, finishing an outgoing TCP connection requires
342            // permission to bind to an unspecified address:
343            return addr.ip().is_unspecified() && addr.port() == 0;
344        }
345        SocketAddrUse::TcpConnect => "tcp",
346        SocketAddrUse::UdpBind | SocketAddrUse::UdpReceive | SocketAddrUse::UdpSend => "udp",
347    };
348    if !allowed_hosts
349        .check_url(&addr.to_string(), scheme)
350        .await
351        .unwrap_or(
352            // TODO: should this trap (somehow)?
353            false,
354        )
355    {
356        return false;
357    }
358    if blocked_networks.is_blocked(&addr) {
359        tracing::error!(
360            "error.type" = "destination_ip_prohibited",
361            ?addr,
362            "destination IP prohibited by runtime config"
363        );
364        return false;
365    }
366    true
367}
368
369pub async fn check_socket_addr_use(
370    allowed_hosts: OutboundAllowedHosts,
371    blocked_networks: BlockedNetworks,
372    addr: SocketAddr,
373    addr_use: SocketAddrUse,
374) -> std::io::Result<()> {
375    if socket_addr_use_allowed(allowed_hosts, blocked_networks, addr, addr_use).await {
376        Ok(())
377    } else {
378        Err(std::io::Error::new(
379            std::io::ErrorKind::PermissionDenied,
380            "An address was not permitted by the socket address check.",
381        ))
382    }
383}