Skip to main content

surrealism_runtime/
net_allow.rs

1//! Resolve `allow_net` strings once at module load time.
2//!
3//! DNS lookups run here (sync, on the thread loading the module — typically not on a Tokio
4//! worker). Used to build the outbound socket allowlist for WASI (`parse_filters`).
5
6use std::net::{IpAddr, SocketAddr, ToSocketAddrs};
7use std::sync::Arc;
8
9use ipnet::IpNet;
10
11/// One resolved allow-net rule, aligned with [`crate::wasi_context`] socket filtering.
12#[derive(Debug, Clone)]
13pub enum ResolvedNetAllow {
14	/// IP or CIDR — any port.
15	Net(IpNet),
16	/// Specific IP and port (from e.g. `host:443` or resolved hostname with port).
17	IpPort(IpAddr, u16),
18}
19
20impl ResolvedNetAllow {
21	/// Same semantics as the WASI `socket_addr_check` filter for outbound connections.
22	pub fn matches_socket_addr(&self, addr: &SocketAddr) -> bool {
23		match self {
24			Self::Net(net) => net.contains(&addr.ip()),
25			Self::IpPort(ip, port) => addr.ip() == *ip && addr.port() == *port,
26		}
27	}
28
29	fn push_from_socket_addr(port: Option<u16>, addr: SocketAddr, out: &mut Vec<Self>) {
30		if let Some(port) = port {
31			out.push(Self::IpPort(addr.ip(), port));
32		} else {
33			out.push(Self::Net(IpNet::from(addr.ip())));
34		}
35	}
36}
37
38/// Resolve `allow_net` entries the same way as SurrealDB `NetTarget::from_str` ordering:
39/// 1. `IpNet` (CIDR)
40/// 2. `IpAddr` → `/32` or `/128`
41/// 3. URL-style host, optional port; hostnames → DNS to IPs (blocking).
42///
43/// Returns an error if any entry fails to parse or any hostname fails to resolve,
44/// aligning with the core pattern where DNS failures propagate rather than being
45/// silently swallowed.
46pub fn resolve_allow_net(entries: &[String]) -> anyhow::Result<Arc<Vec<ResolvedNetAllow>>> {
47	let mut out = Vec::new();
48	for entry in entries {
49		resolve_one(entry, &mut out)?;
50	}
51	Ok(Arc::new(out))
52}
53
54fn resolve_one(entry: &str, out: &mut Vec<ResolvedNetAllow>) -> anyhow::Result<()> {
55	if let Ok(net) = entry.parse::<IpNet>() {
56		out.push(ResolvedNetAllow::Net(net));
57		return Ok(());
58	}
59	if let Ok(ip) = entry.parse::<IpAddr>() {
60		out.push(ResolvedNetAllow::Net(IpNet::from(ip)));
61		return Ok(());
62	}
63	let url = url::Url::parse(&format!("http://{entry}"))
64		.map_err(|e| anyhow::anyhow!("failed to parse allow_net entry '{entry}': {e}"))?;
65	let host =
66		url.host().ok_or_else(|| anyhow::anyhow!("allow_net entry '{entry}' has no host"))?;
67
68	let port: Option<u16> = entry.rsplit_once(':').and_then(|(_, p)| p.parse::<u16>().ok());
69
70	match host {
71		url::Host::Ipv4(ip) => {
72			let ip: IpAddr = ip.into();
73			if let Some(port) = port {
74				out.push(ResolvedNetAllow::IpPort(ip, port));
75			} else {
76				out.push(ResolvedNetAllow::Net(IpNet::from(ip)));
77			}
78		}
79		url::Host::Ipv6(ip) => {
80			let ip: IpAddr = ip.into();
81			if let Some(port) = port {
82				out.push(ResolvedNetAllow::IpPort(ip, port));
83			} else {
84				out.push(ResolvedNetAllow::Net(IpNet::from(ip)));
85			}
86		}
87		url::Host::Domain(domain) => {
88			resolve_hostname(domain, port, out)?;
89		}
90	}
91	Ok(())
92}
93
94/// Blocking DNS — only call from module load / `Runtime::new`, not from async request paths.
95fn resolve_hostname(
96	hostname: &str,
97	port: Option<u16>,
98	out: &mut Vec<ResolvedNetAllow>,
99) -> anyhow::Result<()> {
100	let addrs = (hostname, port.unwrap_or(80))
101		.to_socket_addrs()
102		.map_err(|e| anyhow::anyhow!("failed to resolve allow_net hostname '{hostname}': {e}"))?;
103	for addr in addrs {
104		ResolvedNetAllow::push_from_socket_addr(port, addr, out);
105	}
106	Ok(())
107}
108
109#[cfg(test)]
110mod tests {
111	use std::net::SocketAddr;
112
113	use super::*;
114
115	#[test]
116	fn parses_ip_and_cidr() {
117		let r = resolve_allow_net(&["192.168.1.1".into(), "10.0.0.0/8".into()]).unwrap();
118		assert_eq!(r.len(), 2);
119		let a: SocketAddr = "192.168.1.1:8080".parse().unwrap();
120		assert!(r[0].matches_socket_addr(&a));
121		let inside: SocketAddr = "10.1.2.3:443".parse().unwrap();
122		assert!(r[1].matches_socket_addr(&inside));
123	}
124
125	#[test]
126	fn parses_ip_with_port() {
127		let r = resolve_allow_net(&["192.168.1.1:80".into()]).unwrap();
128		assert_eq!(r.len(), 1);
129		let ok: SocketAddr = "192.168.1.1:80".parse().unwrap();
130		assert!(r[0].matches_socket_addr(&ok));
131		let wrong: SocketAddr = "192.168.1.1:443".parse().unwrap();
132		assert!(!r[0].matches_socket_addr(&wrong));
133	}
134}