use std::collections::{HashMap, HashSet};
use std::mem;
use std::net::{IpAddr, SocketAddr};
use std::pin::Pin;
use std::sync::Arc;
use std::sync::atomic::{AtomicU32, Ordering};
use std::time::Duration;
use parking_lot::Mutex;
use wasmtime::component::{HasData, Resource, ResourceTable};
use wasmtime_wasi::p2::bindings::sockets::ip_name_lookup::{
Host, HostResolveAddressStream, ResolveAddressStream,
};
use wasmtime_wasi::p2::bindings::sockets::network::{self, ErrorCode, IpAddress, Network};
use wasmtime_wasi::p2::{DynPollable, SocketError, subscribe};
use wasmtime_wasi::sockets::SocketAddrUse;
use crate::outbound::{AddrVerdict, OutboundTcpPolicy};
use crate::resolver::Resolver;
const MAX_PINNED_IPS: usize = 1024;
pub(crate) struct OutboundTcpState {
policy: Arc<OutboundTcpPolicy>,
resolver: Arc<Resolver>,
}
impl OutboundTcpState {
pub(crate) fn new(policy: OutboundTcpPolicy, resolver: Resolver) -> Self {
Self {
policy: Arc::new(policy),
resolver: Arc::new(resolver),
}
}
pub(crate) fn io_deadline(&self) -> Duration {
self.policy.io_deadline
}
pub(crate) fn guard(&self) -> TcpGuard {
TcpGuard {
inner: Some(Arc::new(GuardInner {
policy: self.policy.clone(),
resolver: self.resolver.clone(),
pinned: Mutex::new(HashMap::new()),
connects: AtomicU32::new(0),
})),
}
}
}
#[derive(Clone)]
pub(crate) struct TcpGuard {
inner: Option<Arc<GuardInner>>,
}
struct GuardInner {
policy: Arc<OutboundTcpPolicy>,
resolver: Arc<Resolver>,
pinned: Mutex<HashMap<IpAddr, HashSet<String>>>,
connects: AtomicU32,
}
impl TcpGuard {
pub(crate) fn deny_all() -> Self {
Self { inner: None }
}
pub(crate) fn begin_request(&self) {
if let Some(inner) = &self.inner {
inner.connects.store(0, Ordering::SeqCst);
}
}
pub(crate) fn configure_wasi_ctx(&self, builder: &mut wasmtime_wasi::WasiCtxBuilder) {
if self.inner.is_some() {
let guard = self.clone();
builder.socket_addr_check(move |addr, addr_use| {
let verdict = guard.permits(addr, addr_use);
Box::pin(std::future::ready(verdict))
});
builder.allow_udp(false);
}
}
fn permits(&self, addr: SocketAddr, addr_use: SocketAddrUse) -> bool {
let Some(inner) = &self.inner else {
return false;
};
match addr_use {
SocketAddrUse::TcpConnect => {}
SocketAddrUse::TcpBind
| SocketAddrUse::UdpBind
| SocketAddrUse::UdpConnect
| SocketAddrUse::UdpOutgoingDatagram => return false,
}
let ip = addr.ip().to_canonical();
if inner.policy.classify(ip) != AddrVerdict::Allowed {
return false;
}
let matched = {
let pinned = inner.pinned.lock();
let names = pinned.get(&ip);
inner.policy.allow.iter().any(|entry| {
entry.port == addr.port()
&& (entry
.host
.parse::<IpAddr>()
.is_ok_and(|lit| lit.to_canonical() == ip)
|| names.is_some_and(|n| n.contains(&entry.host.to_ascii_lowercase())))
})
};
if !matched {
return false;
}
inner
.connects
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |used| {
(used < inner.policy.max_connections).then(|| used + 1)
})
.is_ok()
}
}
pub(crate) struct PlectoTcpLookup;
impl HasData for PlectoTcpLookup {
type Data<'a> = TcpLookupView<'a>;
}
pub(crate) struct TcpLookupView<'a> {
pub(crate) table: &'a mut ResourceTable,
pub(crate) guard: &'a TcpGuard,
}
impl Host for TcpLookupView<'_> {
fn resolve_addresses(
&mut self,
_network: Resource<Network>,
name: String,
) -> Result<Resource<ResolveAddressStream>, SocketError> {
let Some(inner) = self.guard.inner.as_ref() else {
return Err(ErrorCode::PermanentResolverFailure.into());
};
let lower = name.to_ascii_lowercase();
if !inner.policy.allows_name(&lower) {
return Err(ErrorCode::PermanentResolverFailure.into());
}
let inner = inner.clone();
let task = wasmtime_wasi::runtime::spawn(async move {
let addrs = inner.resolver.resolve(&lower, 0).await.map_err(|e| {
tracing::debug!(host = %lower, error = %e, "outbound-tcp DNS resolution failed");
SocketError::from(ErrorCode::NameUnresolvable)
})?;
if addrs.is_empty() {
return Err(ErrorCode::NameUnresolvable.into());
}
let ips: Vec<IpAddr> = addrs.iter().map(|a| a.ip().to_canonical()).collect();
if ips
.iter()
.any(|ip| inner.policy.classify(*ip) != AddrVerdict::Allowed)
{
return Err(ErrorCode::PermanentResolverFailure.into());
}
{
let mut pinned = inner.pinned.lock();
for ip in &ips {
if !pinned.contains_key(ip) && pinned.len() >= MAX_PINNED_IPS {
return Err(ErrorCode::PermanentResolverFailure.into());
}
pinned.entry(*ip).or_default().insert(lower.clone());
}
}
Ok(ips.into_iter().map(IpAddress::from).collect::<Vec<_>>())
});
let resource = self.table.push(ResolveAddressStream::Waiting(task))?;
Ok(resource)
}
}
impl network::Host for TcpLookupView<'_> {
fn convert_error_code(&mut self, error: SocketError) -> wasmtime::Result<ErrorCode> {
error.downcast()
}
fn network_error_code(
&mut self,
err: Resource<wasmtime::Error>,
) -> wasmtime::Result<Option<ErrorCode>> {
let err = self.table.get(&err)?;
if let Some(err) = err.downcast_ref::<std::io::Error>() {
return Ok(Some(ErrorCode::from(err)));
}
Ok(None)
}
}
impl network::HostNetwork for TcpLookupView<'_> {
fn drop(&mut self, this: Resource<Network>) -> wasmtime::Result<()> {
self.table.delete(this)?;
Ok(())
}
}
impl HostResolveAddressStream for TcpLookupView<'_> {
fn resolve_next_address(
&mut self,
resource: Resource<ResolveAddressStream>,
) -> Result<Option<IpAddress>, SocketError> {
let stream: &mut ResolveAddressStream = self.table.get_mut(&resource)?;
loop {
match stream {
ResolveAddressStream::Waiting(future) => {
match wasmtime_wasi::runtime::poll_noop(Pin::new(future)) {
Some(result) => {
*stream = ResolveAddressStream::Done(result.map(|v| v.into_iter()));
}
None => return Err(ErrorCode::WouldBlock.into()),
}
}
ResolveAddressStream::Done(Ok(iter)) => return Ok(iter.next()),
ResolveAddressStream::Done(slot @ Err(_)) => {
return match mem::replace(slot, Ok(Vec::new().into_iter())) {
Err(e) => Err(e),
Ok(_) => Ok(None),
};
}
}
}
}
fn subscribe(
&mut self,
resource: Resource<ResolveAddressStream>,
) -> wasmtime::Result<Resource<DynPollable>> {
subscribe(self.table, resource)
}
fn drop(&mut self, resource: Resource<ResolveAddressStream>) -> wasmtime::Result<()> {
self.table.delete(resource)?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap as StdHashMap;
use std::time::Duration;
use crate::outbound::TcpAllowEntry;
fn policy(
allow: Vec<TcpAllowEntry>,
allow_private: Vec<&str>,
budget: u32,
) -> OutboundTcpPolicy {
OutboundTcpPolicy {
allow,
allow_private: allow_private.iter().map(|c| c.parse().unwrap()).collect(),
max_connections: budget,
io_deadline: Duration::from_secs(5),
}
}
fn entry(host: &str, port: u16) -> TcpAllowEntry {
TcpAllowEntry {
host: host.to_string(),
port,
}
}
fn guard(policy: OutboundTcpPolicy) -> TcpGuard {
OutboundTcpState::new(policy, Resolver::Static(StdHashMap::new())).guard()
}
fn pin(g: &TcpGuard, name: &str, ip: IpAddr) {
g.inner
.as_ref()
.unwrap()
.pinned
.lock()
.entry(ip)
.or_default()
.insert(name.to_ascii_lowercase());
}
fn addr(ip: &str, port: u16) -> SocketAddr {
SocketAddr::new(ip.parse().unwrap(), port)
}
#[test]
fn deny_all_guard_permits_nothing() {
let g = TcpGuard::deny_all();
assert!(!g.permits(addr("93.184.216.34", 443), SocketAddrUse::TcpConnect));
}
#[test]
fn bind_and_udp_uses_are_denied_even_for_an_allowed_destination() {
let g = guard(policy(vec![entry("8.8.8.8", 853)], vec![], 4));
let dest = addr("8.8.8.8", 853);
assert!(g.permits(dest, SocketAddrUse::TcpConnect));
for blocked in [
SocketAddrUse::TcpBind,
SocketAddrUse::UdpBind,
SocketAddrUse::UdpConnect,
SocketAddrUse::UdpOutgoingDatagram,
] {
assert!(!g.permits(dest, blocked), "{blocked:?} must be denied");
}
}
#[test]
fn reserved_floor_cannot_be_opted_into_at_connect() {
let g = guard(policy(
vec![entry("127.0.0.1", 6379), entry("169.254.169.254", 80)],
vec!["0.0.0.0/0"],
4,
));
assert!(!g.permits(addr("127.0.0.1", 6379), SocketAddrUse::TcpConnect));
assert!(!g.permits(addr("169.254.169.254", 80), SocketAddrUse::TcpConnect));
}
#[test]
fn ip_literal_entry_matches_including_v4_mapped_smuggling() {
let g = guard(policy(vec![entry("8.8.8.8", 853)], vec![], 4));
assert!(g.permits(addr("8.8.8.8", 853), SocketAddrUse::TcpConnect));
assert!(g.permits(addr("::ffff:8.8.8.8", 853), SocketAddrUse::TcpConnect));
assert!(!g.permits(addr("8.8.4.4", 853), SocketAddrUse::TcpConnect));
assert!(!g.permits(addr("8.8.8.8", 443), SocketAddrUse::TcpConnect));
}
#[test]
fn named_entry_requires_the_host_side_pin() {
let g = guard(policy(vec![entry("redis.internal", 6379)], vec![], 4));
let dest = addr("93.184.216.34", 6379);
assert!(
!g.permits(dest, SocketAddrUse::TcpConnect),
"unpinned: denied"
);
pin(&g, "REDIS.internal", "93.184.216.34".parse().unwrap());
assert!(
g.permits(dest, SocketAddrUse::TcpConnect),
"pinned: allowed"
);
}
#[test]
fn pin_does_not_cross_pair_with_another_entrys_port() {
let g = guard(policy(
vec![
entry("redis.internal", 6379),
entry("memcached.internal", 11211),
],
vec![],
4,
));
let redis_ip: IpAddr = "93.184.216.34".parse().unwrap();
pin(&g, "redis.internal", redis_ip);
assert!(g.permits(SocketAddr::new(redis_ip, 6379), SocketAddrUse::TcpConnect));
assert!(
!g.permits(SocketAddr::new(redis_ip, 11211), SocketAddrUse::TcpConnect),
"redis's pinned IP must not open memcached's port"
);
}
#[test]
fn private_destination_needs_the_cidr_optin() {
let denied = guard(policy(vec![entry("10.1.2.3", 6379)], vec![], 4));
assert!(!denied.permits(addr("10.1.2.3", 6379), SocketAddrUse::TcpConnect));
let allowed = guard(policy(
vec![entry("10.1.2.3", 6379)],
vec!["10.1.0.0/16"],
4,
));
assert!(allowed.permits(addr("10.1.2.3", 6379), SocketAddrUse::TcpConnect));
}
#[test]
fn connect_budget_is_consumed_only_by_permitted_connects_and_resets_per_request() {
let g = guard(policy(vec![entry("8.8.8.8", 853)], vec![], 2));
let dest = addr("8.8.8.8", 853);
for _ in 0..10 {
assert!(!g.permits(addr("8.8.4.4", 853), SocketAddrUse::TcpConnect));
}
assert!(g.permits(dest, SocketAddrUse::TcpConnect));
assert!(g.permits(dest, SocketAddrUse::TcpConnect));
assert!(
!g.permits(dest, SocketAddrUse::TcpConnect),
"the third connect exceeds the per-request budget"
);
g.begin_request();
assert!(
g.permits(dest, SocketAddrUse::TcpConnect),
"a new request starts with a fresh budget"
);
}
}