Skip to main content

rings_node/onion/proxy/
mod.rs

1//! Client-side onion proxy planning.
2//!
3//! This module is runtime-neutral: native can bind it to a local HTTP CONNECT listener, while
4//! browser callers can use the same target and service mapping before handing requests to a
5//! browser-specific adapter. A proxy configuration is target-agnostic; each request supplies its
6//! own target authority.
7
8use rings_core::dht::Did;
9
10use crate::error::Error;
11use crate::error::Result;
12use crate::onion::OnionExitDescriptor;
13use crate::onion::OnionExitService;
14use crate::onion::OnionExitTransport;
15pub use crate::onion::OnionProxyTarget;
16use crate::onion::OnionRoute;
17use crate::onion::OnionServiceName;
18use crate::online::OnlineNodeType;
19
20#[cfg(rings_native)]
21pub mod http;
22
23/// Exit service used by native HTTP CONNECT/SOCKS-style byte tunnels.
24pub const ONION_PROXY_TCP_SERVICE: &str = "tcp";
25
26/// Exit service used by HTTPS proxying over a TCP-backed onion exit.
27pub const ONION_PROXY_HTTPS_SERVICE: &str = "https";
28
29/// Proxy protocol requested by the client ingress.
30#[derive(Clone, Copy, Debug, Eq, PartialEq)]
31pub enum OnionProxyProtocol {
32    /// HTTP CONNECT, SOCKS CONNECT, or any other byte tunnel. Requires a native TCP exit.
33    TcpConnect,
34    /// HTTPS proxying over the reserved TCP-backed `https` service.
35    HttpsProxy,
36}
37
38impl OnionProxyProtocol {
39    /// Return the onion-exit service name required by this proxy protocol.
40    pub const fn exit_service(self) -> &'static str {
41        match self {
42            Self::TcpConnect => ONION_PROXY_TCP_SERVICE,
43            Self::HttpsProxy => ONION_PROXY_HTTPS_SERVICE,
44        }
45    }
46
47    /// Return the onion-exit transport required by this proxy protocol.
48    pub const fn exit_transport(self) -> OnionExitTransport {
49        match self {
50            Self::TcpConnect => OnionExitTransport::Tcp,
51            Self::HttpsProxy => OnionExitTransport::Tcp,
52        }
53    }
54
55    /// Return a stable diagnostic label for this proxy protocol.
56    pub const fn label(self) -> &'static str {
57        match self {
58            Self::TcpConnect => "tcp-connect",
59            Self::HttpsProxy => "https-proxy",
60        }
61    }
62
63    fn default_exit_service_name(self) -> OnionServiceName {
64        match self {
65            Self::TcpConnect => OnionServiceName::tcp(),
66            Self::HttpsProxy => OnionServiceName::https(),
67        }
68    }
69}
70
71/// Target-agnostic onion proxy configuration.
72///
73/// A client owns one proxy configuration per ingress style, then resolves one route per target
74/// authority. This keeps browser proxy APIs from becoming one-off URL fetch wrappers.
75#[derive(Clone, Debug, Eq, PartialEq)]
76pub struct OnionProxyConfig {
77    /// Requested ingress protocol.
78    pub protocol: OnionProxyProtocol,
79    service: OnionServiceName,
80    /// Desired hop count including the exit. `0` uses [`crate::onion::DEFAULT_ONION_ROUTE_HOPS`].
81    pub hop_count: usize,
82    /// Whether route selection may use fewer hops when too few relays are live.
83    pub allow_short_paths: bool,
84}
85
86impl OnionProxyConfig {
87    /// Create a proxy configuration for `protocol`.
88    pub fn new(protocol: OnionProxyProtocol, hop_count: usize, allow_short_paths: bool) -> Self {
89        Self {
90            protocol,
91            service: protocol.default_exit_service_name(),
92            hop_count,
93            allow_short_paths,
94        }
95    }
96
97    /// Create a proxy configuration with an explicit exit service.
98    pub fn with_service(
99        protocol: OnionProxyProtocol,
100        service: OnionServiceName,
101        hop_count: usize,
102        allow_short_paths: bool,
103    ) -> Result<Self> {
104        validate_proxy_service(protocol, &service)?;
105        Ok(Self {
106            protocol,
107            service,
108            hop_count,
109            allow_short_paths,
110        })
111    }
112
113    /// Create a native TCP CONNECT proxy configuration.
114    pub fn tcp_connect(hop_count: usize, allow_short_paths: bool) -> Self {
115        Self::new(OnionProxyProtocol::TcpConnect, hop_count, allow_short_paths)
116    }
117
118    /// Create a native TCP CONNECT proxy configuration for a specific TCP exit service.
119    pub fn tcp_connect_service(
120        service: OnionServiceName,
121        hop_count: usize,
122        allow_short_paths: bool,
123    ) -> Result<Self> {
124        Self::with_service(
125            OnionProxyProtocol::TcpConnect,
126            service,
127            hop_count,
128            allow_short_paths,
129        )
130    }
131
132    /// Create an HTTPS proxy configuration.
133    pub fn https_proxy(hop_count: usize, allow_short_paths: bool) -> Self {
134        Self::new(OnionProxyProtocol::HttpsProxy, hop_count, allow_short_paths)
135    }
136
137    /// Return the onion-exit service name required by this proxy.
138    pub fn exit_service(&self) -> &str {
139        self.service.as_str()
140    }
141
142    /// Return the canonical onion-exit service required by this proxy.
143    pub fn exit_service_name(&self) -> &OnionServiceName {
144        &self.service
145    }
146
147    /// Return the onion-exit transport required by this proxy.
148    pub fn exit_transport(&self) -> OnionExitTransport {
149        self.protocol.exit_transport()
150    }
151
152    pub(crate) fn accepts_exit_descriptor(&self, descriptor: &OnionExitDescriptor) -> bool {
153        match self.protocol {
154            OnionProxyProtocol::TcpConnect => {
155                matches!(
156                    descriptor.node_type,
157                    OnlineNodeType::Native | OnlineNodeType::Ffi
158                ) && descriptor
159                    .service
160                    .matches(self.service.as_str(), OnionExitTransport::Tcp)
161            }
162            OnionProxyProtocol::HttpsProxy => {
163                self.service == OnionServiceName::https()
164                    && descriptor
165                        .offers_service_transport(self.service.as_str(), OnionExitTransport::Tcp)
166            }
167        }
168    }
169}
170
171fn validate_proxy_service(protocol: OnionProxyProtocol, service: &OnionServiceName) -> Result<()> {
172    if protocol == OnionProxyProtocol::HttpsProxy && service != &OnionServiceName::https() {
173        return Err(Error::InvalidConfig(format!(
174            "onion HTTPS proxy requires service {:?}",
175            OnionServiceName::https().as_str()
176        )));
177    }
178    if let Some(expected) = OnionExitService::reserved_transport(service.as_str()) {
179        if expected != protocol.exit_transport() {
180            return Err(Error::InvalidConfig(format!(
181                "onion proxy service {:?} requires {:?} transport, got {:?}",
182                service.as_str(),
183                expected,
184                protocol.exit_transport()
185            )));
186        }
187    }
188    Ok(())
189}
190
191/// A proxy route selected for a target.
192#[derive(Clone, Debug, Eq, PartialEq)]
193pub struct OnionProxyRoute {
194    /// Requested ingress protocol.
195    pub protocol: OnionProxyProtocol,
196    /// Target requested by the local client.
197    pub target: OnionProxyTarget,
198    /// Selected route ending at the exit.
199    pub route: OnionRoute,
200}
201
202impl OnionProxyRoute {
203    /// Return the selected exit DID.
204    pub fn exit_did(&self) -> Did {
205        self.route.exit_did()
206    }
207
208    /// Return the exit service used for route selection.
209    pub fn exit_service(&self) -> &str {
210        self.route.service()
211    }
212
213    /// Return the exit transport used for route selection.
214    pub const fn exit_transport(&self) -> OnionExitTransport {
215        self.protocol.exit_transport()
216    }
217}
218
219#[cfg(test)]
220mod tests {
221    use super::*;
222    use crate::error::Error;
223    use crate::error::Result;
224
225    #[test]
226    fn test_proxy_protocol_maps_to_exit_service() {
227        assert_eq!(OnionProxyProtocol::TcpConnect.exit_service(), "tcp");
228        assert_eq!(OnionProxyProtocol::HttpsProxy.exit_service(), "https");
229        assert_eq!(
230            OnionProxyProtocol::TcpConnect.exit_transport(),
231            OnionExitTransport::Tcp
232        );
233        assert_eq!(
234            OnionProxyProtocol::HttpsProxy.exit_transport(),
235            OnionExitTransport::Tcp
236        );
237    }
238
239    #[test]
240    fn test_proxy_config_is_target_agnostic() {
241        let proxy = OnionProxyConfig::https_proxy(3, false);
242
243        assert_eq!(proxy.exit_service(), "https");
244        assert_eq!(proxy.exit_transport(), OnionExitTransport::Tcp);
245        assert_eq!(proxy.hop_count, 3);
246        assert!(!proxy.allow_short_paths);
247    }
248
249    #[test]
250    fn test_tcp_proxy_config_accepts_custom_tcp_service() -> Result<()> {
251        let service = OnionServiceName::parse("web")?;
252        let proxy = OnionProxyConfig::tcp_connect_service(service, 2, true)?;
253
254        assert_eq!(proxy.exit_service(), "web");
255        assert_eq!(proxy.exit_transport(), OnionExitTransport::Tcp);
256        assert_eq!(proxy.hop_count, 2);
257        assert!(proxy.allow_short_paths);
258        Ok(())
259    }
260
261    #[test]
262    fn test_tcp_proxy_config_accepts_https_tcp_service() -> Result<()> {
263        let proxy = OnionProxyConfig::tcp_connect_service(OnionServiceName::https(), 1, false)?;
264
265        assert_eq!(proxy.exit_service(), "https");
266        assert_eq!(proxy.exit_transport(), OnionExitTransport::Tcp);
267        Ok(())
268    }
269
270    #[test]
271    fn test_target_authority_parses_domain_targets() -> Result<()> {
272        let target = OnionProxyTarget::parse_authority("Example.COM.:443")?;
273
274        assert_eq!(target.host(), "example.com");
275        assert_eq!(target.port(), 443);
276        assert_eq!(target.authority(), "example.com:443");
277        Ok(())
278    }
279
280    #[test]
281    fn test_target_authority_parses_ipv6_targets() -> Result<()> {
282        let target = OnionProxyTarget::parse_authority("[2001:db8::1]:8443")?;
283
284        assert_eq!(target.host(), "2001:db8::1");
285        assert_eq!(target.port(), 8443);
286        assert_eq!(target.authority(), "[2001:db8::1]:8443");
287        Ok(())
288    }
289
290    #[test]
291    fn test_target_authority_rejects_missing_port() {
292        assert!(matches!(
293            OnionProxyTarget::parse_authority("example.com"),
294            Err(Error::OnionProxyTarget(
295                crate::onion::OnionProxyTargetError::MissingPort
296            ))
297        ));
298    }
299}