Skip to main content

aauth_protocol/client/
connect.rs

1//! Dev-only connect-to overrides (like curl's `--connect-to`): a request still names and signs for its origin's
2//! authority, but the socket goes elsewhere. It lets a proxy in a container
3//! reach servers that identify as `localhost` on the developer's machine.
4
5use url::Url;
6
7use crate::error::{Error, Result};
8use crate::identifier::authority;
9
10/// Authorities whose requests go to another origin. Empty by default: every
11/// request goes where its URL says.
12#[derive(Clone, Debug, Default)]
13pub struct ConnectTo(Vec<(String, String)>);
14
15/// Where to send a request, and the `Host` it must still carry.
16#[derive(Debug, PartialEq, Eq)]
17pub struct Route {
18    /// The URL to send the request to.
19    pub url: String,
20    /// The `Host` header it must carry, when it goes somewhere other than its URL's authority.
21    pub host: Option<String>,
22}
23
24impl ConnectTo {
25    /// `from_authority=to_origin,...`, e.g. `ps.acme.localhost=http://host.docker.internal:3400`.
26    pub fn parse(raw: &str) -> Result<Self> {
27        raw.split(',')
28            .map(str::trim)
29            .filter(|entry| !entry.is_empty())
30            .map(|entry| {
31                entry
32                    .split_once('=')
33                    .map(|(from, to)| (from.trim().to_ascii_lowercase(), to.trim().to_string()))
34                    .filter(|(from, to)| !from.is_empty() && !to.is_empty())
35                    .ok_or_else(|| {
36                        Error::Invalid(format!("connect-to entry {entry:?} is not from=to"))
37                    })
38            })
39            .collect::<Result<_, _>>()
40            .map(Self)
41    }
42
43    /// Whether no authority is mapped.
44    pub fn is_empty(&self) -> bool {
45        self.0.is_empty()
46    }
47
48    /// Where a request to `url` goes.
49    pub fn route(&self, url: &str) -> Result<Route> {
50        let parsed = Url::parse(url).map_err(|e| Error::Url(format!("{url}: {e}")))?;
51        let from = authority(&parsed).ok_or_else(|| Error::Url(format!("{url} has no host")))?;
52        let Some((_, to)) = self.0.iter().find(|(candidate, _)| *candidate == from) else {
53            return Ok(Route {
54                url: url.to_string(),
55                host: None,
56            });
57        };
58        let mut rest = parsed.path().to_string();
59        if let Some(query) = parsed.query() {
60            rest = format!("{rest}?{query}");
61        }
62        Ok(Route {
63            url: format!("{}{rest}", to.trim_end_matches('/')),
64            host: Some(from),
65        })
66    }
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn a_mapped_authority_dials_elsewhere_and_keeps_its_host() {
75        let connect = ConnectTo::parse(
76            "ps.acme.localhost=http://host.docker.internal:3400, localhost:3400=http://host.docker.internal:3400/",
77        )
78        .unwrap();
79        assert_eq!(
80            connect
81                .route("https://ps.acme.localhost/ps/token?x=1")
82                .unwrap(),
83            Route {
84                url: "http://host.docker.internal:3400/ps/token?x=1".into(),
85                host: Some("ps.acme.localhost".into()),
86            }
87        );
88        assert_eq!(
89            connect.route("https://ps.example/token").unwrap(),
90            Route {
91                url: "https://ps.example/token".into(),
92                host: None,
93            }
94        );
95    }
96
97    #[test]
98    fn a_malformed_entry_is_refused() {
99        assert!(ConnectTo::parse("localhost:3400").is_err());
100        assert!(ConnectTo::parse("").unwrap().is_empty());
101    }
102}