Skip to main content

nexo_pairing/
url_resolver.rs

1//! Resolve the gateway URL for `agent pair start`.
2//!
3//! Priority chain:
4//! 1. `pairing.public_url` (operator-set; highest priority)
5//! 2. `tunnel.url` (active Phase tunnel)
6//! 3. `gateway.remote.url` (legacy)
7//! 4. LAN bind address
8//! 5. error: `gateway only on loopback` — fail-closed.
9//!
10//! Cleartext `ws://` is allowed only on hosts the operator can
11//! reasonably trust to be private:
12//! - `127.0.0.1` / `::1` (loopback)
13//! - RFC1918 (10/8, 172.16/12, 192.168/16)
14//! - link-local (169.254/16)
15//! - `*.local` mDNS hostnames
16//! - `10.0.2.2` (Android emulator)
17//! - any extra host the operator listed in `ws_cleartext_allow`
18//!
19//! Everything else exigirá `wss://`.
20
21use std::net::IpAddr;
22
23#[derive(Debug, Clone)]
24pub struct UrlInputs {
25    pub public_url: Option<String>,
26    pub tunnel_url: Option<String>,
27    pub gateway_remote_url: Option<String>,
28    pub lan_url: Option<String>,
29    /// Extra hostnames where cleartext `ws://` is allowed.
30    pub ws_cleartext_allow_extra: Vec<String>,
31}
32
33#[derive(Debug, Clone)]
34pub struct ResolvedUrl {
35    pub url: String,
36    pub source: &'static str,
37}
38
39#[derive(Debug, Clone, thiserror::Error)]
40pub enum ResolveError {
41    #[error("gateway only bound to loopback; set pairing.public_url, enable tunnel, or use gateway.bind=lan")]
42    LoopbackOnly,
43    #[error("resolved url '{url}' uses ws:// but host is not in the cleartext-allow list (loopback / RFC1918 / link-local / .local / 10.0.2.2 / extras)")]
44    InsecureCleartext { url: String },
45    #[error("invalid url: {0}")]
46    Invalid(String),
47}
48
49pub fn resolve(inputs: &UrlInputs) -> Result<ResolvedUrl, ResolveError> {
50    let candidate = pick_candidate(inputs)?;
51    enforce_security(&candidate.url, &inputs.ws_cleartext_allow_extra)?;
52    Ok(candidate)
53}
54
55fn pick_candidate(inputs: &UrlInputs) -> Result<ResolvedUrl, ResolveError> {
56    if let Some(u) = inputs.public_url.as_ref().filter(|s| !s.trim().is_empty()) {
57        return Ok(ResolvedUrl {
58            url: u.trim().to_string(),
59            source: "pairing.public_url",
60        });
61    }
62    if let Some(u) = inputs.tunnel_url.as_ref().filter(|s| !s.trim().is_empty()) {
63        return Ok(ResolvedUrl {
64            url: u.trim().to_string(),
65            source: "tunnel.url",
66        });
67    }
68    if let Some(u) = inputs
69        .gateway_remote_url
70        .as_ref()
71        .filter(|s| !s.trim().is_empty())
72    {
73        return Ok(ResolvedUrl {
74            url: u.trim().to_string(),
75            source: "gateway.remote.url",
76        });
77    }
78    if let Some(u) = inputs.lan_url.as_ref().filter(|s| !s.trim().is_empty()) {
79        return Ok(ResolvedUrl {
80            url: u.trim().to_string(),
81            source: "gateway.bind=lan",
82        });
83    }
84    Err(ResolveError::LoopbackOnly)
85}
86
87fn enforce_security(url: &str, extras: &[String]) -> Result<(), ResolveError> {
88    let scheme = url
89        .split("://")
90        .next()
91        .ok_or_else(|| ResolveError::Invalid(url.into()))?
92        .to_ascii_lowercase();
93    if scheme == "wss" || scheme == "https" {
94        return Ok(());
95    }
96    if scheme != "ws" && scheme != "http" {
97        return Err(ResolveError::Invalid(format!(
98            "unsupported scheme: {scheme}"
99        )));
100    }
101    let after = url.split("://").nth(1).unwrap_or("");
102    let host = after
103        .split('/')
104        .next()
105        .unwrap_or("")
106        .split(':')
107        .next()
108        .unwrap_or("");
109    if host.is_empty() {
110        return Err(ResolveError::Invalid(url.into()));
111    }
112    if is_cleartext_allowed(host, extras) {
113        return Ok(());
114    }
115    Err(ResolveError::InsecureCleartext { url: url.into() })
116}
117
118fn is_cleartext_allowed(host: &str, extras: &[String]) -> bool {
119    if extras.iter().any(|h| h.eq_ignore_ascii_case(host)) {
120        return true;
121    }
122    if host.eq_ignore_ascii_case("localhost") || host == "10.0.2.2" {
123        return true;
124    }
125    if host.to_ascii_lowercase().ends_with(".local") {
126        return true;
127    }
128    match host.parse::<IpAddr>() {
129        Ok(IpAddr::V4(v4)) => {
130            if v4.is_loopback() || v4.is_link_local() || v4.is_private() {
131                return true;
132            }
133            false
134        }
135        Ok(IpAddr::V6(v6)) => {
136            // `Ipv6Addr::is_unicast_link_local` is unstable on MSRV 1.80;
137            // hand-rolled check: fe80::/10
138            let segs = v6.segments();
139            v6.is_loopback() || (segs[0] & 0xffc0) == 0xfe80
140        }
141        Err(_) => false,
142    }
143}
144
145#[cfg(test)]
146mod tests {
147    use super::*;
148
149    fn empty() -> UrlInputs {
150        UrlInputs {
151            public_url: None,
152            tunnel_url: None,
153            gateway_remote_url: None,
154            lan_url: None,
155            ws_cleartext_allow_extra: vec![],
156        }
157    }
158
159    #[test]
160    fn loopback_only_fails_closed() {
161        let err = resolve(&empty()).unwrap_err();
162        assert!(matches!(err, ResolveError::LoopbackOnly));
163    }
164
165    #[test]
166    fn priority_public_over_tunnel() {
167        let mut i = empty();
168        i.public_url = Some("wss://op.example.com".into());
169        i.tunnel_url = Some("wss://abc.ngrok.app".into());
170        let r = resolve(&i).unwrap();
171        assert_eq!(r.source, "pairing.public_url");
172        assert_eq!(r.url, "wss://op.example.com");
173    }
174
175    #[test]
176    fn priority_tunnel_over_remote() {
177        let mut i = empty();
178        i.tunnel_url = Some("wss://abc.ngrok.app".into());
179        i.gateway_remote_url = Some("wss://legacy".into());
180        let r = resolve(&i).unwrap();
181        assert_eq!(r.source, "tunnel.url");
182    }
183
184    #[test]
185    fn lan_ws_allowed_for_rfc1918() {
186        let mut i = empty();
187        i.lan_url = Some("ws://192.168.1.10:9090".into());
188        let r = resolve(&i).unwrap();
189        assert_eq!(r.source, "gateway.bind=lan");
190    }
191
192    #[test]
193    fn ws_blocked_on_public_host() {
194        let mut i = empty();
195        i.public_url = Some("ws://api.example.com".into());
196        let err = resolve(&i).unwrap_err();
197        assert!(matches!(err, ResolveError::InsecureCleartext { .. }));
198    }
199
200    #[test]
201    fn ws_allowed_on_localhost() {
202        let mut i = empty();
203        i.public_url = Some("ws://localhost:9090".into());
204        resolve(&i).unwrap();
205    }
206
207    #[test]
208    fn ws_allowed_on_dot_local_mdns() {
209        let mut i = empty();
210        i.public_url = Some("ws://kitchen-pi.local:9090".into());
211        resolve(&i).unwrap();
212    }
213
214    #[test]
215    fn ws_allowed_on_extras() {
216        let mut i = empty();
217        i.public_url = Some("ws://my.cool.host:9090".into());
218        i.ws_cleartext_allow_extra = vec!["my.cool.host".into()];
219        resolve(&i).unwrap();
220    }
221
222    #[test]
223    fn ws_allowed_on_android_emu() {
224        let mut i = empty();
225        i.public_url = Some("ws://10.0.2.2:9090".into());
226        resolve(&i).unwrap();
227    }
228
229    #[test]
230    fn link_local_v4_allowed() {
231        let mut i = empty();
232        i.lan_url = Some("ws://169.254.1.5:9090".into());
233        resolve(&i).unwrap();
234    }
235}