Skip to main content

cratefield_runtime_native/ports/
mod.rs

1//! Port adapters over native infrastructure (issue #19): the system
2//! clock on tokio, `tokio::spawn`, `reqwest` and Redis — plus client-IP
3//! resolution, the one place a native deployment must behave
4//! differently from Workers.
5
6mod blob;
7mod clock;
8mod defer;
9mod http;
10mod kv;
11mod rate_limit;
12mod realtime;
13
14pub use blob::DirBlob;
15pub use clock::TokioClock;
16pub use defer::SpawnDefer;
17pub use http::{OutboundOptions, ReqwestClient, vet_ip_ok};
18pub use kv::{RedisBundle, RedisKv, RedisPortError, redis_from_env};
19pub use rate_limit::RedisRateLimiter;
20pub use realtime::{Connection, InProcessRealtime};
21
22use axum::http::{HeaderMap, HeaderName};
23use std::net::IpAddr;
24use std::str::FromStr as _;
25
26use cratefield_core::Config;
27
28/// The header the runtime normalizes the resolved client IP into. Core's
29/// `client_ip` reads it first on every target, so modules (which only
30/// ever call `cratefield_core::client_ip`) see the same header contract on
31/// Workers and native. The server middleware sets it **after** stripping
32/// every inbound forwarding header, so its value is always the one this
33/// runtime resolved — never what a client sent.
34pub const CLIENT_IP_HEADER: &str = "cf-connecting-ip";
35
36/// Forwarding headers stripped from every inbound request before the
37/// router sees it. `cratefield_core::client_ip` trusts
38/// `x-forwarded-for`'s first hop on native builds, so an unsanitized
39/// header would let any client pick its own rate-limit key; stripping
40/// here is what makes [`trusted_proxy_headers`] the single trust point.
41const STRIPPED: [&str; 4] = [
42    "cf-connecting-ip",
43    "x-forwarded-for",
44    "x-real-ip",
45    "forwarded",
46];
47
48/// The `TRUSTED_HOSTS` list: extra `Host` values this deployment answers
49/// to, beyond the venture's own domain (issue #129).
50///
51/// A native process serves whatever `Host` a caller sends, and `Host` is
52/// the key ADR 0008's database-per-tenant resolution will read once phase
53/// three lands — so an unvalidated one is both a link-forgery and
54/// cache-poisoning vector today and the cross-tenant vector tomorrow.
55/// Entries are compared without their port, lowercased. Empty is not
56/// permissive: the venture's own domain still answers.
57#[must_use]
58pub(crate) fn trusted_hosts(config: &dyn Config) -> Vec<String> {
59    config
60        .get("TRUSTED_HOSTS")
61        .map(|raw| {
62            raw.split(',')
63                .map(|entry| entry.trim().to_ascii_lowercase())
64                .filter(|entry| !entry.is_empty())
65                .collect()
66        })
67        .unwrap_or_default()
68}
69
70/// The hosts a venture answers to by construction: its apex domain, the
71/// `api.<domain>` the architecture says the API serves, and the host of
72/// its own public URL (issue #129).
73#[must_use]
74pub(crate) fn venture_hosts(venture: &cratefield_core::Venture) -> Vec<String> {
75    let domain = venture.domain.trim().to_ascii_lowercase();
76    let mut hosts = Vec::new();
77    if !domain.is_empty() {
78        hosts.push(format!("api.{domain}"));
79        hosts.push(domain);
80    }
81    if let Some(host) = venture
82        .public_url
83        .split("://")
84        .nth(1)
85        .and_then(|rest| rest.split('/').next())
86        .map(|host| host.trim().to_ascii_lowercase())
87        .filter(|host| !host.is_empty())
88    {
89        let host = host_without_port(&host);
90        if !hosts.iter().any(|known| known == &host) {
91            hosts.push(host);
92        }
93    }
94    hosts
95}
96
97/// The host part of an authority, without its port. IPv6 literals keep
98/// their brackets, which is how a `Host` header spells them.
99#[must_use]
100pub(crate) fn host_without_port(authority: &str) -> String {
101    let authority = authority.trim().to_ascii_lowercase();
102    if let Some(rest) = authority.strip_prefix('[') {
103        return match rest.split_once(']') {
104            Some((inner, _)) => format!("[{inner}]"),
105            None => authority,
106        };
107    }
108    match authority.rsplit_once(':') {
109        Some((host, port)) if port.chars().all(|c| c.is_ascii_digit()) => host.to_owned(),
110        _ => authority,
111    }
112}
113
114/// Whether `host` is a loopback name or literal, with or without a port.
115/// Local development and every in-process test reach the server this way.
116#[must_use]
117pub(crate) fn is_loopback_host(host: &str) -> bool {
118    let host = host_without_port(host);
119    host == "localhost"
120        || host.ends_with(".localhost")
121        || host == "[::1]"
122        || host
123            .parse::<std::net::IpAddr>()
124            .is_ok_and(|ip| ip.is_loopback())
125}
126
127/// The `TRUSTED_PROXY_HEADERS` list: the header names this deployment is
128/// willing to believe the client IP from (comma-separated, e.g.
129/// `TRUSTED_PROXY_HEADERS=x-forwarded-for` behind one nginx, or
130/// `cf-connecting-ip` behind Cloudflare). Default: **empty** — an
131/// unconfigured deployment must not trust any forwarding header, and the
132/// runtime resolves every address from the socket peer instead.
133///
134/// Trust is a property of the network path, not the header: only list a
135/// header when a proxy you control overwrites it on every request.
136/// Entries are lowercased; invalid header names are ignored; `forwarded`
137/// (RFC 7239) is accepted as a name but its `for=` value format is not
138/// parsed — use an `x-forwarded-for`-style list or `cf-connecting-ip`.
139#[must_use]
140pub fn trusted_proxy_headers(config: &dyn Config) -> Vec<String> {
141    config
142        .get("TRUSTED_PROXY_HEADERS")
143        .map(|raw| {
144            raw.split(',')
145                .map(str::trim)
146                .filter(|entry| !entry.is_empty())
147                .map(str::to_ascii_lowercase)
148                .filter(|name| HeaderName::from_str(name).is_ok())
149                .collect::<Vec<_>>()
150        })
151        .unwrap_or_default()
152}
153
154/// The client IP for one request, in trust order:
155///
156/// 1. the first [`trusted_proxy_headers`] header present with a value
157///    whose first comma-separated entry parses as an IP address;
158/// 2. else the TCP peer address the server observed.
159///
160/// `None` only when both are unavailable (no trusted header matched and
161/// the middleware ran without connection info, e.g. a test's `oneshot`).
162///
163/// This function decides; the server middleware enforces. It resolves
164/// the address here, strips every forwarding header, and writes the
165/// result into [`CLIENT_IP_HEADER`](crate::CLIENT_IP_HEADER) — so by the time a module calls
166/// `cratefield_core::client_ip(headers)`, the only forwarding header left
167/// carries exactly this runtime's verdict.
168#[must_use]
169pub fn client_ip(headers: &HeaderMap, trusted: &[String], peer: Option<IpAddr>) -> Option<IpAddr> {
170    for name in trusted {
171        let parsed = headers
172            .get(name.as_str())
173            .and_then(|value| value.to_str().ok())
174            .and_then(|list| list.split(',').next())
175            .map(str::trim)
176            .and_then(|first| first.parse::<IpAddr>().ok());
177        if let Some(ip) = parsed {
178            return Some(ip);
179        }
180    }
181    peer
182}
183
184/// Applies the resolution to a request's headers: strip
185/// [`STRIPPED`], then set [`CLIENT_IP_HEADER`] to `resolved`. This is
186/// what the server middleware calls; it is `pub(crate)` visible only
187/// through tests that assert the sanitized view modules actually see.
188pub(crate) fn normalize_headers(headers: &mut HeaderMap, resolved: Option<IpAddr>) {
189    for name in STRIPPED {
190        headers.remove(HeaderName::from_static(name));
191    }
192    if let Some(ip) = resolved
193        && let Ok(value) = axum::http::HeaderValue::from_str(&ip.to_string())
194    {
195        headers.insert(HeaderName::from_static(CLIENT_IP_HEADER), value);
196    }
197}
198
199#[cfg(test)]
200mod tests {
201    use super::*;
202    use axum::http::HeaderValue;
203    use cratefield_core::MapConfig;
204
205    fn headers(pairs: &[(&str, &str)]) -> HeaderMap {
206        use std::str::FromStr as _;
207        let mut map = HeaderMap::new();
208        for (name, value) in pairs {
209            map.insert(
210                HeaderName::from_str(name).expect("test header names are valid"),
211                HeaderValue::from_str(value).expect("valid header value"),
212            );
213        }
214        map
215    }
216
217    #[test]
218    fn trusted_list_defaults_to_empty() {
219        assert!(trusted_proxy_headers(&MapConfig::default()).is_empty());
220        let cfg = MapConfig::from_pairs([("TRUSTED_PROXY_HEADERS", "  ")]);
221        assert!(trusted_proxy_headers(&cfg).is_empty());
222    }
223
224    #[test]
225    fn trusted_list_parses_lowercased_and_drops_invalid() {
226        let cfg = MapConfig::from_pairs([(
227            "TRUSTED_PROXY_HEADERS",
228            "X-Forwarded-For, x-real-ip, bad name,,cf-connecting-ip",
229        )]);
230        assert_eq!(
231            trusted_proxy_headers(&cfg),
232            ["x-forwarded-for", "x-real-ip", "cf-connecting-ip"]
233        );
234    }
235
236    #[test]
237    fn unconfigured_trusts_no_header_only_the_peer() {
238        let headers = headers(&[
239            ("x-forwarded-for", "6.6.6.6"),
240            ("cf-connecting-ip", "7.7.7.7"),
241            ("x-real-ip", "8.8.8.8"),
242        ]);
243        let peer: IpAddr = "203.0.113.7".parse().unwrap();
244        assert_eq!(
245            client_ip(&headers, &[], Some(peer)),
246            Some(peer),
247            "with no trusted headers, forwarding headers are decoration"
248        );
249    }
250
251    #[test]
252    fn trusted_header_wins_in_config_order() {
253        let headers = headers(&[
254            ("x-real-ip", "198.51.100.2"),
255            ("x-forwarded-for", "198.51.100.1, 10.0.0.1"),
256        ]);
257        let trusted = vec!["x-forwarded-for".to_owned(), "x-real-ip".to_owned()];
258        let resolved = client_ip(&headers, &trusted, Some("203.0.113.7".parse().unwrap()));
259        assert_eq!(
260            resolved.map(|ip| ip.to_string()).as_deref(),
261            Some("198.51.100.1")
262        );
263    }
264
265    #[test]
266    fn garbage_in_trusted_header_falls_through_to_next_then_peer() {
267        let headers = headers(&[("x-real-ip", "not-an-ip")]);
268        let peer: IpAddr = "203.0.113.7".parse().unwrap();
269        let trusted = vec!["x-real-ip".to_owned()];
270        assert_eq!(client_ip(&headers, &trusted, Some(peer)), Some(peer));
271    }
272
273    #[test]
274    fn normalize_strips_everything_and_sets_the_verdict() {
275        let mut headers = headers(&[
276            ("x-forwarded-for", "6.6.6.6"),
277            ("forwarded", "for=7.7.7.7"),
278            ("x-real-ip", "8.8.8.8"),
279            ("cf-connecting-ip", "9.9.9.9"),
280        ]);
281        normalize_headers(&mut headers, Some("203.0.113.7".parse::<IpAddr>().unwrap()));
282        // The spoofed values are gone; the verdict is the only survivor.
283        assert_eq!(headers.get("x-forwarded-for"), None);
284        assert_eq!(headers.get("forwarded"), None);
285        assert_eq!(headers.get("x-real-ip"), None);
286        assert_eq!(
287            headers.get(CLIENT_IP_HEADER).and_then(|v| v.to_str().ok()),
288            Some("203.0.113.7")
289        );
290        // And core — what the modules call — reads exactly that verdict.
291        assert_eq!(
292            cratefield_core::client_ip(&headers).as_deref(),
293            Some("203.0.113.7")
294        );
295    }
296}
297
298#[cfg(test)]
299mod host_tests {
300    use super::{host_without_port, is_loopback_host, trusted_hosts, venture_hosts};
301    use cratefield_core::{MapConfig, Venture};
302
303    #[test]
304    fn a_venture_answers_to_its_own_domain_and_api_subdomain() {
305        let venture = Venture::new("cratefield-waitlist", "cratefield.com")
306            .public_url("https://cratefield.com");
307        let hosts = venture_hosts(&venture);
308        assert!(hosts.contains(&"cratefield.com".to_owned()), "{hosts:?}");
309        assert!(
310            hosts.contains(&"api.cratefield.com".to_owned()),
311            "{hosts:?}"
312        );
313        // A public URL on another host is answered to as well.
314        let split =
315            Venture::new("auth", "factory0.ventures").public_url("https://auth.factory0.ventures/");
316        let hosts = venture_hosts(&split);
317        assert!(
318            hosts.contains(&"auth.factory0.ventures".to_owned()),
319            "{hosts:?}"
320        );
321    }
322
323    #[test]
324    fn ports_are_not_part_of_the_comparison_and_ipv6_keeps_its_brackets() {
325        assert_eq!(host_without_port("cratefield.com:8443"), "cratefield.com");
326        assert_eq!(host_without_port("CrateField.com"), "cratefield.com");
327        assert_eq!(host_without_port("[::1]:8080"), "[::1]");
328        assert_eq!(host_without_port("[fd00::1]"), "[fd00::1]");
329        // Not a port: a bare colon with a non-numeric tail stays put.
330        assert_eq!(host_without_port("host:notaport"), "host:notaport");
331    }
332
333    #[test]
334    fn loopback_is_recognized_in_every_spelling_local_work_uses() {
335        for host in [
336            "localhost",
337            "localhost:8080",
338            "127.0.0.1",
339            "127.0.0.1:8080",
340            "127.9.9.9",
341            "[::1]",
342            "[::1]:8080",
343            "app.localhost",
344        ] {
345            assert!(is_loopback_host(host), "{host} is loopback");
346        }
347        for host in ["cratefield.com", "10.0.0.1", "[fd00::1]", "evil.example"] {
348            assert!(!is_loopback_host(host), "{host} is not loopback");
349        }
350    }
351
352    #[test]
353    fn the_extra_host_list_is_parsed_like_the_trusted_proxy_list() {
354        assert!(trusted_hosts(&MapConfig::default()).is_empty());
355        let cfg = MapConfig::from_pairs([("TRUSTED_HOSTS", " Api.Example.com , , other.test ")]);
356        assert_eq!(trusted_hosts(&cfg), ["api.example.com", "other.test"]);
357    }
358}