Skip to main content

hennery_kernel/
egress.rs

1//! The outbound HTTP policy (kernel spec §7.1; gateway spec §4, §5.7): every
2//! request hennery itself sends to the network — the gateway's proxy, its
3//! OAuth calls, Web Push — goes through an [`EgressClient`].
4//!
5//! - **Built once, cloned freely.** [`Egress::new`] builds its `reqwest`
6//!   clients once: one per [`Allowance`], and a second, internal-only one
7//!   for plain `http` under [`Allowance::InternalNetwork`]. [`Egress`] and
8//!   [`EgressClient`] are cheap to clone (`reqwest::Client` is an `Arc`
9//!   inside) and share connection pools with their clones. The two
10//!   allowances never share a pool: a pooled
11//!   connection skips the resolver, so a public-only request must never
12//!   ride one an internal-network request opened.
13//! - **No way around the check.** The only way to send is
14//!   [`EgressClient::send`] or [`EgressClient::send_streaming`], which run
15//!   [`check_url`] on the request's URL first. The `reqwest::Client` inside
16//!   is never handed out. A caller holding an `http::Request` (for example,
17//!   rmcp's `OAuthHttpClient`) converts it with
18//!   `Request::try_from` and sends it here.
19//! - **The URL check** ([`check_url`]): `https`, or plain `http` to
20//!   loopback (a loopback literal or `localhost`); under
21//!   [`Allowance::InternalNetwork`] also plain `http` to an internal
22//!   address or a name (plan 8b-ii, below). No credentials in the URL; an
23//!   IP-literal host must be public under [`Allowance::PublicOnly`].
24//!   reqwest never asks the resolver about a literal, so this check is the
25//!   only one a literal gets. `url::Url` has
26//!   already normalised the odd IPv4 spellings (`2130706433`, `0x7f.1`,
27//!   `0177.0.0.1`, `127.1`) to the address they name.
28//! - **Names are resolved by hennery** (the system resolver) and, under
29//!   `PublicOnly`, refused when **any** address is not public — not filtered
30//!   — so one record pointing inward refuses the name. The connection then
31//!   goes only to the addresses that were checked. `localhost` is never
32//!   looked up: it is `127.0.0.1` and `::1` (RFC 6761), so plain `http` to it
33//!   stays on this machine whatever the resolver or hosts file says.
34//! - **Public** ([`is_public`]): IPv4 outside the special-purpose ranges
35//!   (this network and `0.0.0.0`, RFC 1918, CGNAT, loopback, link-local
36//!   including `169.254.169.254`, IETF protocol assignments, the
37//!   documentation and benchmarking ranges, 6to4 relay anycast, multicast,
38//!   reserved and broadcast); IPv6 inside global unicast `2000::/3` and
39//!   outside its special ranges (`2001::/23` with Teredo and benchmarking,
40//!   documentation `2001:db8::/32` and `3fff::/20`, 6to4 `2002::/16`). So
41//!   `::`, `::1`, IPv4-mapped `::ffff:0:0/96`, NAT64 `64:ff9b::/96` and
42//!   `64:ff9b:1::/48`, unique-local `fc00::/7`, link-local `fe80::/10` and
43//!   multicast `ff00::/8` are never public.
44//! - **Plain `http` inside the internal network** (plan 8b-ii, the
45//!   operator's decision of 2026-10-02): only under
46//!   [`Allowance::InternalNetwork`], and only to internal addresses — RFC
47//!   1918, loopback and unique-local IPv6, except AWS's metadata address
48//!   `fd00:ec2::254`. A public address stays `https` only even then, and so
49//!   do the addresses that are neither: link-local and that metadata
50//!   address (cloud metadata services answer plain `http` there), CGNAT (it
51//!   may be the carrier's network), documentation, multicast, `0.0.0.0`,
52//!   IPv4-mapped, NAT64, 6to4 and Teredo (they may lead to a public host). A
53//!   literal is checked by the URL check; a name goes through a third
54//!   client, used for nothing but plain `http` under `InternalNetwork`,
55//!   whose resolver refuses the name if **any** address is not internal.
56//! - **Never** a proxy from the environment (`HTTP_PROXY`, `HTTPS_PROXY`,
57//!   `ALL_PROXY`): it would take the checked connection elsewhere and hand
58//!   it the request. **Never** a followed redirect: a 3xx is the caller's
59//!   answer.
60//! - **Timeouts** ([`Timeouts`]): a connect timeout on every connection.
61//!   [`EgressClient::send`] bounds the whole exchange, body included, by the
62//!   request's own timeout or else [`Timeouts::request`].
63//!   [`EgressClient::send_streaming`] bounds only the wait for the response
64//!   head the same way; the caller reads the body for as long as it wants
65//!   and decides for itself when a stream has been idle too long. A body
66//!   that fails while it is read (`send`'s deadline included) fails with a
67//!   `reqwest::Error` from [`Response`], not an [`EgressError`]: strip its
68//!   URL with `without_url()` before logging it.
69//! - **Errors** ([`EgressError`]) never carry the URL (its path or query can
70//!   hold a secret), so they are safe to log.
71//! - **Per-caller limits** ([`Limiter`]): a counter of permits per caller
72//!   key (the gateway's is the connection), refusing past its cap with
73//!   [`Busy`] instead of queueing. A caller keeps one for concurrent requests
74//!   and one for open streams; the gateway answers [`Busy`] with 503.
75
76use std::collections::HashMap;
77use std::fmt;
78use std::future::Future;
79use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr};
80use std::pin::Pin;
81use std::sync::{Arc, Mutex, PoisonError};
82use std::time::Duration;
83
84use reqwest::dns::{Addrs, Name, Resolve, Resolving};
85/// What a caller needs to build a request and read its answer, so it
86/// depends on the kernel, not on reqwest.
87pub use reqwest::{Method, Request, Response, StatusCode, Url, header};
88use url::Host;
89
90/// Whether a request may reach non-public addresses.
91#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum Allowance {
93    /// Public addresses only: Web Push always, and every gateway connection
94    /// the operator has not marked "internal network".
95    PublicOnly,
96    /// Any address over `https`, and internal addresses only over plain
97    /// `http`: a gateway connection the operator marked "internal network"
98    /// (gateway spec §5.7), and nothing else.
99    InternalNetwork,
100}
101
102/// The policy's timeouts.
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub struct Timeouts {
105    /// Opening one TCP connection.
106    pub connect: Duration,
107    /// The default deadline of a request with none of its own: for
108    /// [`EgressClient::send`] the whole exchange, for
109    /// [`EgressClient::send_streaming`] the response head.
110    pub request: Duration,
111}
112
113impl Timeouts {
114    pub const DEFAULT: Timeouts = Timeouts {
115        connect: Duration::from_secs(10),
116        request: Duration::from_secs(30),
117    };
118}
119
120/// The policy's clients: one per [`Allowance`], and the internal-network
121/// one with a second, for plain `http`.
122#[derive(Debug, Clone)]
123pub struct Egress {
124    public: EgressClient,
125    internal: EgressClient,
126}
127
128impl Egress {
129    pub fn new(timeouts: Timeouts) -> anyhow::Result<Egress> {
130        Ok(Egress {
131            public: EgressClient::build(Allowance::PublicOnly, timeouts)?,
132            internal: EgressClient::build(Allowance::InternalNetwork, timeouts)?,
133        })
134    }
135
136    /// The client for `allowance`.
137    pub fn client(&self, allowance: Allowance) -> EgressClient {
138        match allowance {
139            Allowance::PublicOnly => self.public.clone(),
140            Allowance::InternalNetwork => self.internal.clone(),
141        }
142    }
143}
144
145/// Sends requests under one [`Allowance`]; see the module docs.
146#[derive(Debug, Clone)]
147pub struct EgressClient {
148    http: reqwest::Client,
149    /// Plain `http` under [`Allowance::InternalNetwork`]: its resolver lets
150    /// a name reach internal addresses only. `None` under `PublicOnly`.
151    plain: Option<reqwest::Client>,
152    allowance: Allowance,
153    timeouts: Timeouts,
154}
155
156impl EgressClient {
157    fn build(allowance: Allowance, timeouts: Timeouts) -> anyhow::Result<EgressClient> {
158        EgressClient::build_with(allowance, timeouts, system_lookup)
159    }
160
161    /// [`EgressClient::build`] with the lookup a name goes to after the
162    /// `localhost` rule: the system resolver, or a unit test's table. The
163    /// checks on its answer are the same either way.
164    fn build_with(allowance: Allowance, timeouts: Timeouts, system: SystemLookup) -> anyhow::Result<EgressClient> {
165        let (reach, plain) = match allowance {
166            Allowance::PublicOnly => (Reach::Public, None),
167            Allowance::InternalNetwork => (Reach::Any, Some(http_client(Reach::Internal, timeouts, system)?)),
168        };
169        Ok(EgressClient {
170            http: http_client(reach, timeouts, system)?,
171            plain,
172            allowance,
173            timeouts,
174        })
175    }
176
177    /// The client a request to `url` goes through: plain `http` under
178    /// `InternalNetwork` through the internal-only one.
179    fn for_url(&self, url: &Url) -> &reqwest::Client {
180        match &self.plain {
181            Some(plain) if url.scheme() == "http" => plain,
182            _ => &self.http,
183        }
184    }
185
186    pub fn allowance(&self) -> Allowance {
187        self.allowance
188    }
189
190    /// Checks `request`'s URL and sends it; the whole exchange, body
191    /// included, must finish within the request's timeout, or else
192    /// [`Timeouts::request`].
193    pub async fn send(&self, mut request: Request) -> Result<Response, EgressError> {
194        check_url(request.url(), self.allowance)?;
195        if request.timeout().is_none() {
196            *request.timeout_mut() = Some(self.timeouts.request);
197        }
198        self.for_url(request.url()).execute(request).await.map_err(classify)
199    }
200
201    /// Checks `request`'s URL and sends it; the response head must arrive
202    /// within the request's timeout, or else [`Timeouts::request`], and the
203    /// body then has no deadline.
204    pub async fn send_streaming(&self, mut request: Request) -> Result<Response, EgressError> {
205        check_url(request.url(), self.allowance)?;
206        let head = request.timeout_mut().take().unwrap_or(self.timeouts.request);
207        let http = self.for_url(request.url());
208        match tokio::time::timeout(head, http.execute(request)).await {
209            Ok(sent) => sent.map_err(classify),
210            Err(_) => Err(EgressError::Timeout),
211        }
212    }
213}
214
215fn http_client(reach: Reach, timeouts: Timeouts, system: SystemLookup) -> anyhow::Result<reqwest::Client> {
216    Ok(reqwest::Client::builder()
217        .user_agent(concat!("hennery/", env!("CARGO_PKG_VERSION")))
218        .no_proxy()
219        .redirect(reqwest::redirect::Policy::none())
220        .dns_resolver(Arc::new(CheckedResolver { reach, system }))
221        .connect_timeout(timeouts.connect)
222        .build()?)
223}
224
225/// Why the policy refused a request.
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum Refused {
228    /// Not `https`, and not `http` to loopback (or, under
229    /// [`Allowance::InternalNetwork`], to an internal address or a name).
230    Scheme(String),
231    /// The URL carries a user name or password.
232    Credentials,
233    /// The URL has no host.
234    NoHost,
235    /// An IP-literal host that is not public.
236    Address(IpAddr),
237    /// A name that resolved to an address the request may not reach: not
238    /// public, or, for plain `http` under `InternalNetwork`, not internal.
239    Resolved { host: String, addr: IpAddr },
240}
241
242impl fmt::Display for Refused {
243    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244        match self {
245            Refused::Scheme(scheme) => write!(
246                f,
247                "{scheme:?} URLs are refused: https only, or http to loopback or, on an internal network, to an internal address or a name"
248            ),
249            Refused::Credentials => f.write_str("URLs with credentials are refused"),
250            Refused::NoHost => f.write_str("the URL has no host"),
251            Refused::Address(addr) => write!(f, "{addr} is not a public address"),
252            Refused::Resolved { host, addr } => {
253                write!(f, "{host} resolves to {addr}, which this request may not reach")
254            }
255        }
256    }
257}
258
259impl std::error::Error for Refused {}
260
261/// A request that did not get a response.
262#[derive(Debug)]
263pub enum EgressError {
264    /// The policy refused it; nothing was sent.
265    Refused(Refused),
266    /// Its deadline passed.
267    Timeout,
268    /// Any other failure, without the URL.
269    Http(reqwest::Error),
270}
271
272impl fmt::Display for EgressError {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        match self {
275            EgressError::Refused(refused) => write!(f, "refused by the egress policy: {refused}"),
276            EgressError::Timeout => f.write_str("the request timed out"),
277            EgressError::Http(err) => write!(f, "{err}"),
278        }
279    }
280}
281
282impl std::error::Error for EgressError {
283    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
284        match self {
285            EgressError::Refused(refused) => Some(refused),
286            EgressError::Timeout => None,
287            EgressError::Http(err) => Some(err),
288        }
289    }
290}
291
292impl From<Refused> for EgressError {
293    fn from(refused: Refused) -> EgressError {
294        EgressError::Refused(refused)
295    }
296}
297
298/// The resolver's refusal comes back wrapped by the connector; find it.
299fn classify(err: reqwest::Error) -> EgressError {
300    let mut source: Option<&(dyn std::error::Error + 'static)> = Some(&err);
301    while let Some(cause) = source {
302        if let Some(refused) = cause.downcast_ref::<Refused>() {
303            return EgressError::Refused(refused.clone());
304        }
305        source = cause.source();
306    }
307    if err.is_timeout() {
308        EgressError::Timeout
309    } else {
310        EgressError::Http(err.without_url())
311    }
312}
313
314/// The URL check every request passes before it is sent; see the module
315/// docs. A name is checked later, by the resolver, once it has addresses.
316pub fn check_url(url: &Url, allowance: Allowance) -> Result<(), Refused> {
317    match url.scheme() {
318        "https" => {}
319        "http" if is_loopback_host(url) => {}
320        "http" if allowance == Allowance::InternalNetwork && may_be_internal(url) => {}
321        other => return Err(Refused::Scheme(other.to_owned())),
322    }
323    if !url.username().is_empty() || url.password().is_some() {
324        return Err(Refused::Credentials);
325    }
326    let addr = match url.host() {
327        None => return Err(Refused::NoHost),
328        Some(Host::Domain(_)) => return Ok(()),
329        Some(Host::Ipv4(addr)) => IpAddr::V4(addr),
330        Some(Host::Ipv6(addr)) => IpAddr::V6(addr),
331    };
332    if allowance == Allowance::PublicOnly && !is_public(addr) {
333        return Err(Refused::Address(addr));
334    }
335    Ok(())
336}
337
338/// `https`, or plain `http` to loopback: the scheme rule for OAuth's URLs
339/// (gateway spec §4, §5.7), which stays this even for a connection marked
340/// "internal network", whose client [`check_url`] lets send plain `http`
341/// to internal addresses (plan 8b-ii). The OAuth caller checks it itself
342/// before sending (plan 8f).
343pub fn is_https_or_loopback(url: &Url) -> bool {
344    match url.scheme() {
345        "https" => true,
346        "http" => is_loopback_host(url),
347        _ => false,
348    }
349}
350
351/// Why [`read_capped`] did not return a body.
352#[derive(Debug)]
353pub enum BodyError {
354    /// Longer than the cap, declared or as read.
355    TooLarge,
356    /// It failed while it was read (its deadline included), without the
357    /// URL.
358    Failed(reqwest::Error),
359}
360
361impl fmt::Display for BodyError {
362    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
363        match self {
364            BodyError::TooLarge => f.write_str("the response body is over its limit"),
365            BodyError::Failed(err) => write!(f, "{err}"),
366        }
367    }
368}
369
370impl std::error::Error for BodyError {}
371
372/// `response`'s body, refused past `max` bytes: a declared length over it
373/// is refused before anything is read, and the read stops at the first
374/// chunk past it. [`EgressClient::send`] bounds a request's time, not its
375/// size; this is the size (the egress plan's O3), for every caller that
376/// reads a whole body (OAuth's documents and token responses, plan 8f).
377pub async fn read_capped(mut response: Response, max: usize) -> Result<Vec<u8>, BodyError> {
378    if response.content_length().is_some_and(|len| len > max as u64) {
379        return Err(BodyError::TooLarge);
380    }
381    let mut out = Vec::new();
382    while let Some(chunk) = response
383        .chunk()
384        .await
385        .map_err(|err| BodyError::Failed(err.without_url()))?
386    {
387        if out.len() + chunk.len() > max {
388            return Err(BodyError::TooLarge);
389        }
390        out.extend_from_slice(&chunk);
391    }
392    Ok(out)
393}
394
395fn is_loopback_host(url: &Url) -> bool {
396    match url.host() {
397        Some(Host::Domain(name)) => is_localhost(name),
398        Some(Host::Ipv4(addr)) => addr.is_loopback(),
399        Some(Host::Ipv6(addr)) => addr.is_loopback(),
400        None => false,
401    }
402}
403
404/// Whether plain `http` under `InternalNetwork` may go to `url`'s host: an
405/// internal literal, or a name, which the internal-only client's resolver
406/// then checks.
407fn may_be_internal(url: &Url) -> bool {
408    match url.host() {
409        Some(Host::Domain(_)) => true,
410        Some(Host::Ipv4(addr)) => is_internal(IpAddr::V4(addr)),
411        Some(Host::Ipv6(addr)) => is_internal(IpAddr::V6(addr)),
412        None => false,
413    }
414}
415
416/// IPv4 ranges that are internal: plain `http` may reach them under
417/// `InternalNetwork` (plan 8b-ii).
418const V4_INTERNAL: &[(Ipv4Addr, u8)] = &[
419    (Ipv4Addr::new(10, 0, 0, 0), 8),     // RFC 1918
420    (Ipv4Addr::new(127, 0, 0, 0), 8),    // loopback
421    (Ipv4Addr::new(172, 16, 0, 0), 12),  // RFC 1918
422    (Ipv4Addr::new(192, 168, 0, 0), 16), // RFC 1918
423];
424
425/// IPv6 ranges that are internal; no IPv4-mapped, NAT64, 6to4 or Teredo
426/// address is, since any of them may lead to a public IPv4 host.
427const V6_INTERNAL: &[(Ipv6Addr, u8)] = &[
428    (Ipv6Addr::LOCALHOST, 128),                      // loopback
429    (Ipv6Addr::new(0xfc00, 0, 0, 0, 0, 0, 0, 0), 7), // unique-local
430];
431
432/// A cloud metadata service inside unique-local: AWS's IPv6 one. Never
433/// internal, so plain `http` never reaches it (an open question for the
434/// operator, refused until answered).
435const V6_METADATA: Ipv6Addr = Ipv6Addr::new(0xfd00, 0x0ec2, 0, 0, 0, 0, 0, 0x0254);
436
437/// Whether `addr` is internal; see the module docs. Only an allowlist, so
438/// everything else — public or neither — is not.
439fn is_internal(addr: IpAddr) -> bool {
440    match addr {
441        IpAddr::V4(addr) => V4_INTERNAL.iter().any(|&range| in_v4(addr, range)),
442        IpAddr::V6(addr) => addr != V6_METADATA && V6_INTERNAL.iter().any(|&range| in_v6(addr, range)),
443    }
444}
445
446/// IPv4 ranges that are never public (the IANA special-purpose registry).
447const V4_NOT_PUBLIC: &[(Ipv4Addr, u8)] = &[
448    (Ipv4Addr::new(0, 0, 0, 0), 8),       // "this network", 0.0.0.0
449    (Ipv4Addr::new(10, 0, 0, 0), 8),      // RFC 1918
450    (Ipv4Addr::new(100, 64, 0, 0), 10),   // CGNAT
451    (Ipv4Addr::new(127, 0, 0, 0), 8),     // loopback
452    (Ipv4Addr::new(169, 254, 0, 0), 16),  // link-local, 169.254.169.254
453    (Ipv4Addr::new(172, 16, 0, 0), 12),   // RFC 1918
454    (Ipv4Addr::new(192, 0, 0, 0), 24),    // IETF protocol assignments
455    (Ipv4Addr::new(192, 0, 2, 0), 24),    // documentation (TEST-NET-1)
456    (Ipv4Addr::new(192, 88, 99, 0), 24),  // 6to4 relay anycast
457    (Ipv4Addr::new(192, 168, 0, 0), 16),  // RFC 1918
458    (Ipv4Addr::new(198, 18, 0, 0), 15),   // benchmarking
459    (Ipv4Addr::new(198, 51, 100, 0), 24), // documentation (TEST-NET-2)
460    (Ipv4Addr::new(203, 0, 113, 0), 24),  // documentation (TEST-NET-3)
461    (Ipv4Addr::new(224, 0, 0, 0), 4),     // multicast
462    (Ipv4Addr::new(240, 0, 0, 0), 4),     // reserved, 255.255.255.255
463];
464
465/// Global unicast: the only IPv6 that can be public.
466const V6_GLOBAL: (Ipv6Addr, u8) = (Ipv6Addr::new(0x2000, 0, 0, 0, 0, 0, 0, 0), 3);
467
468/// The ranges inside global unicast that are not public.
469const V6_NOT_PUBLIC: &[(Ipv6Addr, u8)] = &[
470    (Ipv6Addr::new(0x2001, 0, 0, 0, 0, 0, 0, 0), 23), // IETF protocol assignments: Teredo, benchmarking
471    (Ipv6Addr::new(0x2001, 0xdb8, 0, 0, 0, 0, 0, 0), 32), // documentation
472    (Ipv6Addr::new(0x2002, 0, 0, 0, 0, 0, 0, 0), 16), // 6to4, which embeds any IPv4 address
473    (Ipv6Addr::new(0x3fff, 0, 0, 0, 0, 0, 0, 0), 20), // documentation
474];
475
476fn in_v4(addr: Ipv4Addr, (net, len): (Ipv4Addr, u8)) -> bool {
477    let mask = u32::MAX.checked_shl(32 - u32::from(len)).unwrap_or(0);
478    u32::from(addr) & mask == u32::from(net) & mask
479}
480
481fn in_v6(addr: Ipv6Addr, (net, len): (Ipv6Addr, u8)) -> bool {
482    let mask = u128::MAX.checked_shl(128 - u32::from(len)).unwrap_or(0);
483    u128::from(addr) & mask == u128::from(net) & mask
484}
485
486/// Whether `addr` is a public address; see the module docs.
487pub fn is_public(addr: IpAddr) -> bool {
488    match addr {
489        IpAddr::V4(addr) => !V4_NOT_PUBLIC.iter().any(|&range| in_v4(addr, range)),
490        IpAddr::V6(addr) => in_v6(addr, V6_GLOBAL) && !V6_NOT_PUBLIC.iter().any(|&range| in_v6(addr, range)),
491    }
492}
493
494/// Which addresses a client's resolver lets a name reach.
495#[derive(Debug, Clone, Copy, PartialEq, Eq)]
496enum Reach {
497    /// Public only: every `PublicOnly` request.
498    Public,
499    /// Any: `https` under `InternalNetwork`.
500    Any,
501    /// Internal only: plain `http` under `InternalNetwork`.
502    Internal,
503}
504
505/// The addresses a name resolved to, if `reach` permits all of them.
506fn checked(host: &str, addrs: Vec<SocketAddr>, reach: Reach) -> Result<Vec<SocketAddr>, Refused> {
507    let permitted = |addr: &SocketAddr| match reach {
508        Reach::Public => is_public(addr.ip()),
509        Reach::Any => true,
510        Reach::Internal => is_internal(addr.ip()),
511    };
512    if let Some(addr) = addrs.iter().find(|addr| !permitted(addr)) {
513        return Err(Refused::Resolved {
514            host: host.to_owned(),
515            addr: addr.ip(),
516        });
517    }
518    Ok(addrs)
519}
520
521/// `localhost` (RFC 6761), with or without its trailing dot.
522fn is_localhost(name: &str) -> bool {
523    name.trim_end_matches('.').eq_ignore_ascii_case("localhost")
524}
525
526/// What `localhost` resolves to, without a lookup (RFC 6761 §6.3): no
527/// resolver or hosts file can point it anywhere but loopback, which the
528/// scheme rule's plain `http` to `localhost` relies on.
529const LOCALHOST: [SocketAddr; 2] = [
530    SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0),
531    SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0),
532];
533
534/// Where a name other than `localhost` is looked up.
535type SystemLookup = fn(String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>>;
536
537/// The system resolver.
538fn system_lookup(host: String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
539    Box::pin(async move { Ok(tokio::net::lookup_host((host.as_str(), 0)).await?.collect()) })
540}
541
542/// The addresses `host` resolves to: [`LOCALHOST`] for `localhost`, the
543/// system resolver's answer for any other name.
544async fn lookup(host: &str, system: SystemLookup) -> std::io::Result<Vec<SocketAddr>> {
545    if is_localhost(host) {
546        return Ok(LOCALHOST.to_vec());
547    }
548    system(host.to_owned()).await
549}
550
551/// Resolves names with [`lookup`] and applies [`checked`].
552struct CheckedResolver {
553    reach: Reach,
554    system: SystemLookup,
555}
556
557impl Resolve for CheckedResolver {
558    fn resolve(&self, name: Name) -> Resolving {
559        let (reach, system) = (self.reach, self.system);
560        Box::pin(async move {
561            let host = name.as_str().to_owned();
562            let addrs = lookup(&host, system).await?;
563            let addrs = checked(&host, addrs, reach)?;
564            Ok(Box::new(addrs.into_iter()) as Addrs)
565        })
566    }
567}
568
569/// A caller is over its cap.
570#[derive(Debug, Clone, Copy, PartialEq, Eq)]
571pub struct Busy;
572
573impl fmt::Display for Busy {
574    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
575        f.write_str("too many requests in flight for this caller")
576    }
577}
578
579impl std::error::Error for Busy {}
580
581/// At most `cap` permits held at once per caller key. Clones share the
582/// counts. Only keys holding a permit are tracked, so the table is bounded
583/// by what is in flight.
584#[derive(Debug, Clone)]
585pub struct Limiter {
586    cap: usize,
587    in_use: Arc<Mutex<HashMap<String, usize>>>,
588}
589
590impl Limiter {
591    pub fn new(cap: usize) -> Limiter {
592        Limiter {
593            cap,
594            in_use: Arc::default(),
595        }
596    }
597
598    /// A permit for `key`, released when dropped, or [`Busy`] at once if
599    /// `key` already holds `cap`.
600    pub fn try_acquire(&self, key: &str) -> Result<Permit, Busy> {
601        let mut in_use = self.in_use.lock().unwrap_or_else(PoisonError::into_inner);
602        let held = in_use.get(key).copied().unwrap_or(0);
603        if held >= self.cap {
604            return Err(Busy);
605        }
606        in_use.insert(key.to_owned(), held + 1);
607        Ok(Permit {
608            key: key.to_owned(),
609            in_use: self.in_use.clone(),
610        })
611    }
612}
613
614/// One of a [`Limiter`]'s permits; dropping it releases it.
615#[derive(Debug)]
616#[must_use = "the permit is released when dropped"]
617pub struct Permit {
618    key: String,
619    in_use: Arc<Mutex<HashMap<String, usize>>>,
620}
621
622impl Drop for Permit {
623    fn drop(&mut self) {
624        let mut in_use = self.in_use.lock().unwrap_or_else(PoisonError::into_inner);
625        if let Some(held) = in_use.get_mut(&self.key) {
626            *held -= 1;
627            if *held == 0 {
628                in_use.remove(&self.key);
629            }
630        }
631    }
632}
633
634#[cfg(test)]
635mod tests {
636    use super::*;
637
638    fn refused(url: &str) -> bool {
639        let url = Url::parse(url).unwrap();
640        let public = check_url(&url, Allowance::PublicOnly);
641        assert_eq!(check_url(&url, Allowance::InternalNetwork), Ok(()), "{url}");
642        matches!(public, Err(Refused::Address(_)))
643    }
644
645    /// Plan 8b (b): every non-public literal is refused under `PublicOnly`
646    /// and allowed under `InternalNetwork`.
647    #[test]
648    fn non_public_literals_are_refused() {
649        for url in [
650            "https://0.0.0.0/",
651            "https://0.1.2.3/",
652            "https://10.0.0.1/",
653            "https://100.64.0.1/",
654            "https://100.127.255.254/",
655            "https://127.0.0.1/",
656            "https://127.255.255.254/",
657            "https://169.254.169.254/",
658            "https://172.16.0.1/",
659            "https://172.31.255.254/",
660            "https://192.0.0.1/",
661            "https://192.0.2.1/",
662            "https://192.88.99.1/",
663            "https://192.168.1.1/",
664            "https://198.18.0.1/",
665            "https://198.19.255.254/",
666            "https://198.51.100.1/",
667            "https://203.0.113.1/",
668            "https://224.0.0.1/",
669            "https://239.255.255.250/",
670            "https://240.0.0.1/",
671            "https://255.255.255.255/",
672            "https://[::]/",
673            "https://[::1]/",
674            "https://[::ffff:127.0.0.1]/",
675            "https://[::ffff:10.0.0.1]/",
676            "https://[::ffff:8.8.8.8]/",
677            "https://[::127.0.0.1]/",
678            "https://[64:ff9b::7f00:1]/",
679            "https://[64:ff9b::808:808]/",
680            "https://[64:ff9b:1::1]/",
681            "https://[100::1]/",
682            "https://[2001::1]/",
683            "https://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/",
684            "https://[2001:2::1]/",
685            "https://[2001:db8::1]/",
686            "https://[2002:c0a8:101::1]/",
687            "https://[2002:808:808::1]/",
688            "https://[3fff::1]/",
689            "https://[5f00::1]/",
690            "https://[fc00::1]/",
691            "https://[fd12:3456::1]/",
692            "https://[fe80::1]/",
693            "https://[fec0::1]/",
694            "https://[ff02::1]/",
695        ] {
696            assert!(refused(url), "{url} was not refused");
697        }
698    }
699
700    /// Plan 8b (b): `url::Url` normalises the odd IPv4 spellings to the
701    /// address they name, so they are refused like the dotted form.
702    #[test]
703    fn odd_ipv4_spellings_are_refused_as_the_address_they_name() {
704        for url in [
705            "http://2130706433/",
706            "http://0x7f000001/",
707            "http://0x7f.1/",
708            "http://0177.0.0.1/",
709            "http://127.1/",
710            "http://127.0.0.1./",
711            "https://0/",
712        ] {
713            let parsed = Url::parse(url).unwrap();
714            assert!(
715                matches!(parsed.host(), Some(Host::Ipv4(_))),
716                "{url} -> {:?}",
717                parsed.host()
718            );
719            assert!(refused(url), "{url} was not refused");
720        }
721        assert_eq!(
722            Url::parse("http://2130706433/").unwrap().host(),
723            Some(Host::Ipv4(Ipv4Addr::LOCALHOST))
724        );
725    }
726
727    /// Well-known public resolvers' addresses: only parsed and classified,
728    /// never connected to. The documentation ranges cannot stand in for
729    /// them, since they are rightly not public.
730    #[test]
731    fn public_literals_and_names_pass() {
732        for url in [
733            "https://8.8.8.8/",
734            "https://1.1.1.1/",
735            "https://100.63.255.255/",
736            "https://100.128.0.0/",
737            "https://172.15.255.255/",
738            "https://172.32.0.0/",
739            "https://198.17.255.255/",
740            "https://198.20.0.0/",
741            "https://223.255.255.255/",
742            "https://[2606:4700:4700::1111]/",
743            "https://[2001:200::1]/",
744            "https://[2a00:1450:4001::1]/",
745            "https://example.com/",
746        ] {
747            let parsed = Url::parse(url).unwrap();
748            assert_eq!(check_url(&parsed, Allowance::PublicOnly), Ok(()), "{url}");
749        }
750    }
751
752    /// Plan 8b (e): https, or http to loopback; under `PublicOnly`, plain
753    /// http to anything else is refused by its scheme.
754    #[test]
755    fn plain_http_only_to_loopback() {
756        for url in [
757            "http://127.0.0.1/",
758            "http://[::1]/",
759            "http://localhost/",
760            "http://LOCALHOST./",
761        ] {
762            let parsed = Url::parse(url).unwrap();
763            assert_eq!(check_url(&parsed, Allowance::InternalNetwork), Ok(()), "{url}");
764            // Loopback passes the scheme rule under `PublicOnly` too (its
765            // address is what refuses it there).
766            assert!(
767                !matches!(check_url(&parsed, Allowance::PublicOnly), Err(Refused::Scheme(_))),
768                "{url}"
769            );
770        }
771        for url in [
772            "http://example.com/",
773            "http://10.0.0.1/",
774            "http://localhost.example.com/",
775            "http://8.8.8.8/",
776        ] {
777            let parsed = Url::parse(url).unwrap();
778            assert!(
779                matches!(check_url(&parsed, Allowance::PublicOnly), Err(Refused::Scheme(_))),
780                "{url}"
781            );
782        }
783        for url in ["ws://localhost/", "ws://10.0.0.1/", "file:///etc/passwd"] {
784            let parsed = Url::parse(url).unwrap();
785            for allowance in [Allowance::PublicOnly, Allowance::InternalNetwork] {
786                assert!(
787                    matches!(check_url(&parsed, allowance), Err(Refused::Scheme(_))),
788                    "{url} {allowance:?}"
789                );
790            }
791        }
792    }
793
794    /// Plan 8b-ii: under `InternalNetwork`, plain `http` may go to an
795    /// internal literal — every internal range, at its edges — or to a
796    /// name (the internal-only client's resolver checks it). A public
797    /// literal, or one that is neither public nor internal, stays `https`
798    /// only. Under `PublicOnly` nothing changes.
799    #[test]
800    fn plain_http_under_internal_network_only_to_internal_addresses() {
801        for url in [
802            "http://10.0.0.1/",
803            "http://10.255.255.254/",
804            "http://127.0.0.2/",
805            "http://127.255.255.254/",
806            "http://172.16.0.1/",
807            "http://172.31.255.254/",
808            "http://192.168.0.1/",
809            "http://192.168.255.254/",
810            "http://[::1]/",
811            "http://[fc00::1]/",
812            "http://[fdff:ffff::1]/",
813            "http://[fd00:ec2::253]/",
814            "http://[fd00:ec2::255]/",
815            "http://nas.lan/",
816            "http://example.com/",
817        ] {
818            let parsed = Url::parse(url).unwrap();
819            assert_eq!(check_url(&parsed, Allowance::InternalNetwork), Ok(()), "{url}");
820            if !is_loopback_host(&parsed) {
821                assert_eq!(
822                    check_url(&parsed, Allowance::PublicOnly),
823                    Err(Refused::Scheme("http".into())),
824                    "{url}"
825                );
826            }
827        }
828        for url in [
829            "http://8.8.8.8/",
830            "http://9.255.255.255/",
831            "http://11.0.0.0/",
832            "http://126.255.255.255/",
833            "http://128.0.0.0/",
834            "http://100.64.0.1/",
835            "http://100.100.100.200/",
836            "http://169.254.169.254/",
837            "http://169.254.0.1/",
838            "http://[fd00:ec2::254]/",
839            "http://172.15.255.255/",
840            "http://172.32.0.0/",
841            "http://192.167.255.255/",
842            "http://192.169.0.0/",
843            "http://0.0.0.0/",
844            "http://192.0.2.1/",
845            "http://198.18.0.1/",
846            "http://224.0.0.1/",
847            "http://255.255.255.255/",
848            "http://[2606:4700:4700::1111]/",
849            "http://[::]/",
850            "http://[::2]/",
851            "http://[fbff:ffff::1]/",
852            "http://[fe00::]/",
853            "http://[fe80::1]/",
854            "http://[fec0::1]/",
855            "http://[ff02::1]/",
856            "http://[::ffff:10.0.0.1]/",
857            "http://[::ffff:8.8.8.8]/",
858            "http://[64:ff9b::a00:1]/",
859            "http://[2002:a00:1::1]/",
860            "http://[2001:0:4136:e378:8000:63bf:3fff:fdd2]/",
861        ] {
862            let parsed = Url::parse(url).unwrap();
863            for allowance in [Allowance::PublicOnly, Allowance::InternalNetwork] {
864                assert_eq!(
865                    check_url(&parsed, allowance),
866                    Err(Refused::Scheme("http".into())),
867                    "{url} {allowance:?}"
868                );
869            }
870        }
871        // Everything else a public-only request may not carry, it still may not.
872        for (url, refusal) in [
873            ("http://user@10.0.0.1/", Refused::Credentials),
874            ("http://user:secret@nas.lan/", Refused::Credentials),
875            ("ws://10.0.0.1/", Refused::Scheme("ws".into())),
876            ("ftp://nas.lan/", Refused::Scheme("ftp".into())),
877        ] {
878            let parsed = Url::parse(url).unwrap();
879            assert_eq!(check_url(&parsed, Allowance::InternalNetwork), Err(refusal), "{url}");
880        }
881    }
882
883    /// Plan 8b-ii: the internal-only client's resolver refuses a name with
884    /// **any** address that is not internal, public or not, in any order.
885    #[test]
886    fn a_name_for_plain_http_needs_every_address_internal() {
887        let lan: SocketAddr = "192.168.1.10:0".parse().unwrap();
888        let ula: SocketAddr = "[fd12:3456::1]:0".parse().unwrap();
889        let public: SocketAddr = "93.184.215.14:0".parse().unwrap();
890        let doc: SocketAddr = "192.0.2.1:0".parse().unwrap();
891        let mapped: SocketAddr = "[::ffff:10.0.0.1]:0".parse().unwrap();
892        let metadata: SocketAddr = "[fd00:ec2::254]:0".parse().unwrap();
893        assert_eq!(checked("nas.lan", vec![lan, ula], Reach::Internal), Ok(vec![lan, ula]));
894        for addrs in [
895            vec![public],
896            vec![lan, public],
897            vec![public, lan],
898            vec![lan, doc],
899            vec![lan, mapped],
900            vec![ula, metadata],
901            vec![metadata],
902        ] {
903            let Err(Refused::Resolved { host, addr }) = checked("nas.lan", addrs.clone(), Reach::Internal) else {
904                panic!("{addrs:?} was not refused");
905            };
906            assert_eq!(host, "nas.lan");
907            assert!(!is_internal(addr), "{addr}");
908        }
909    }
910
911    /// A lookup table standing in for the system resolver.
912    fn table(host: String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
913        Box::pin(async move {
914            let addrs: &[&str] = match host.as_str() {
915                "lan.test" => &["127.0.0.1:0"],
916                "mixed.test" => &["127.0.0.1:0", "192.0.2.1:0"],
917                "public.test" => &["192.0.2.1:0"],
918                _ => return Err(std::io::Error::other("no such name")),
919            };
920            Ok(addrs.iter().map(|addr| addr.parse().unwrap()).collect())
921        })
922    }
923
924    /// Plan 8b-ii: under `InternalNetwork`, plain `http` to a name goes
925    /// through the internal-only client: a name whose addresses are all
926    /// internal connects, one with any other address is refused and nothing
927    /// connects; `https` does not go through it. (Names come from `table`,
928    /// whose other address is a documentation one, never connected to; the
929    /// listener is on loopback, which is internal.)
930    #[tokio::test]
931    async fn plain_http_to_a_name_reaches_only_internal_addresses() {
932        use std::sync::atomic::{AtomicUsize, Ordering};
933        use tokio::io::{AsyncReadExt, AsyncWriteExt};
934        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
935        let port = listener.local_addr().unwrap().port();
936        let accepted = Arc::new(AtomicUsize::new(0));
937        let count = accepted.clone();
938        tokio::spawn(async move {
939            while let Ok((mut stream, _)) = listener.accept().await {
940                count.fetch_add(1, Ordering::SeqCst);
941                tokio::spawn(async move {
942                    let mut buf = [0u8; 1024];
943                    let _ = stream.read(&mut buf).await;
944                    let _ = stream
945                        .write_all(b"HTTP/1.1 200 OK\r\ncontent-length: 2\r\nconnection: close\r\n\r\nok")
946                        .await;
947                });
948            }
949        });
950        let timeouts = Timeouts {
951            connect: Duration::from_secs(2),
952            request: Duration::from_secs(5),
953        };
954        let client = EgressClient::build_with(Allowance::InternalNetwork, timeouts, table).unwrap();
955        let get = |host: &str| Request::new(Method::GET, Url::parse(&format!("http://{host}:{port}/x")).unwrap());
956        let response = client.send(get("lan.test")).await.unwrap();
957        assert_eq!(response.text().await.unwrap(), "ok");
958        assert_eq!(accepted.load(Ordering::SeqCst), 1);
959        for host in ["mixed.test", "public.test"] {
960            for streaming in [false, true] {
961                let sent = if streaming {
962                    client.send_streaming(get(host)).await
963                } else {
964                    client.send(get(host)).await
965                };
966                let Err(EgressError::Refused(Refused::Resolved { addr, .. })) = sent else {
967                    panic!("{host}: {sent:?}");
968                };
969                assert_eq!(addr, "192.0.2.1".parse::<IpAddr>().unwrap());
970            }
971        }
972        assert_eq!(accepted.load(Ordering::SeqCst), 1, "a refused name connected");
973        // `https` under `InternalNetwork` keeps the client that reaches any
974        // address: `mixed.test` connects (and fails the TLS handshake with
975        // the plain listener), rather than being refused.
976        let https = Request::new(
977            Method::GET,
978            Url::parse(&format!("https://mixed.test:{port}/x")).unwrap(),
979        );
980        let sent = client.send(https).await;
981        assert!(matches!(sent, Err(EgressError::Http(_))), "{sent:?}");
982        assert_eq!(accepted.load(Ordering::SeqCst), 2, "https did not connect");
983    }
984
985    /// Plan 8b (d): refuse, not filter: one inward record refuses the name.
986    #[test]
987    fn a_name_with_any_non_public_address_is_refused() {
988        let public: SocketAddr = "93.184.215.14:0".parse().unwrap();
989        let private: SocketAddr = "10.0.0.1:0".parse().unwrap();
990        let mapped: SocketAddr = "[::ffff:127.0.0.1]:0".parse().unwrap();
991        assert_eq!(checked("example.com", vec![public], Reach::Public), Ok(vec![public]));
992        for addrs in [vec![public, private], vec![private, public], vec![public, mapped]] {
993            let Err(Refused::Resolved { host, addr }) = checked("example.com", addrs.clone(), Reach::Public) else {
994                panic!("{addrs:?} was not refused");
995            };
996            assert_eq!(host, "example.com");
997            assert!(!is_public(addr));
998            assert_eq!(checked("example.com", addrs.clone(), Reach::Any), Ok(addrs));
999        }
1000    }
1001
1002    /// The review's B1: `localhost` is loopback by rule, not by whatever the
1003    /// system resolver answers, so plain `http` to it never leaves the
1004    /// machine.
1005    #[tokio::test]
1006    async fn localhost_is_loopback_without_a_lookup() {
1007        fn no_lookup(host: String) -> Pin<Box<dyn Future<Output = std::io::Result<Vec<SocketAddr>>> + Send>> {
1008            panic!("{host} was looked up")
1009        }
1010        for reach in [Reach::Any, Reach::Internal, Reach::Public] {
1011            let resolver = CheckedResolver {
1012                reach,
1013                system: no_lookup,
1014            };
1015            for name in ["localhost", "localhost.", "LocalHost."] {
1016                match resolver.resolve(name.parse().unwrap()).await {
1017                    Ok(addrs) => {
1018                        assert_ne!(reach, Reach::Public, "{name}");
1019                        assert_eq!(addrs.collect::<Vec<_>>(), LOCALHOST.to_vec(), "{name}");
1020                    }
1021                    Err(err) => {
1022                        assert_eq!(reach, Reach::Public, "{name}: {err}");
1023                        assert!(err.downcast_ref::<Refused>().is_some(), "{name}: {err}");
1024                    }
1025                }
1026            }
1027        }
1028        assert!(!is_localhost("localhost.example.com"));
1029        assert!(!is_localhost("foo.localhost"));
1030        // And under `PublicOnly`, loopback is refused like any inward answer.
1031        let Err(Refused::Resolved { .. }) = checked("localhost", LOCALHOST.to_vec(), Reach::Public) else {
1032            panic!("localhost was not refused");
1033        };
1034    }
1035
1036    /// The contract's types cross tasks: the proxy moves a `Permit` into a
1037    /// response body stream, and every caller shares its clients.
1038    const _: fn() = || {
1039        fn send_sync<T: Send + Sync + 'static>() {}
1040        send_sync::<Egress>();
1041        send_sync::<EgressClient>();
1042        send_sync::<Limiter>();
1043        send_sync::<Permit>();
1044        send_sync::<EgressError>();
1045    };
1046
1047    #[test]
1048    fn a_limiter_refuses_past_its_cap_per_key_and_forgets_released_keys() {
1049        let limiter = Limiter::new(2);
1050        let a1 = limiter.try_acquire("a").unwrap();
1051        let a2 = limiter.try_acquire("a").unwrap();
1052        assert_eq!(limiter.try_acquire("a").unwrap_err(), Busy);
1053        // Another key has its own count; a clone shares them.
1054        let b1 = limiter.clone().try_acquire("b").unwrap();
1055        assert_eq!(limiter.clone().try_acquire("a").unwrap_err(), Busy);
1056        drop(a1);
1057        let a3 = limiter.try_acquire("a").unwrap();
1058        assert_eq!(limiter.try_acquire("a").unwrap_err(), Busy);
1059        drop((a2, a3, b1));
1060        assert!(limiter.in_use.lock().unwrap().is_empty(), "released keys are forgotten");
1061        // A refusal leaves nothing behind either.
1062        let none = Limiter::new(0);
1063        assert_eq!(none.try_acquire("a").unwrap_err(), Busy);
1064        assert!(none.in_use.lock().unwrap().is_empty());
1065    }
1066}