Skip to main content

shell_tunnel/relay/
proxy.rs

1//! Forwarding public requests down a device's data connection.
2//!
3//! One in-flight request owns one data connection for its lifetime. That is the
4//! whole reason this relay needs no stream multiplexer: with a connection per
5//! request there are no interleaved streams to demultiplex, so there is no
6//! framing format of our own to design, version, and defend. The cost — a pool
7//! of idle connections to keep refilled — is the same trade frp makes with
8//! `pool_count`, and it is bounded work rather than a protocol.
9
10use std::time::Duration;
11
12use serde::{Deserialize, Serialize};
13
14/// How long a request waits for a free data connection before giving up.
15pub const POOL_WAIT: Duration = Duration::from_secs(5);
16
17/// How long a proxied request may take before the relay gives up on the device.
18pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(120);
19
20/// Request metadata sent to the device ahead of the body.
21///
22/// Sent once per connection rather than per frame: this is a header, not a
23/// multiplexing envelope.
24#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
25pub struct ProxyRequest {
26    /// HTTP method.
27    pub method: String,
28    /// Path and query, relative to the device's local server.
29    pub path: String,
30    /// Headers to replay, including `Authorization` — which the relay forwards
31    /// verbatim and never inspects.
32    pub headers: Vec<(String, String)>,
33    /// Whether this is a WebSocket upgrade.
34    ///
35    /// Carried as a field rather than as `Upgrade`/`Connection` headers: those
36    /// are hop-by-hop and describe the *client's* connection to the relay, so
37    /// replaying them would contradict the filtering everything else goes
38    /// through. The device performs its own local handshake instead.
39    #[serde(default)]
40    pub websocket: bool,
41}
42
43/// Response metadata returned by the device ahead of the body.
44#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
45pub struct ProxyResponse {
46    /// HTTP status code.
47    pub status: u16,
48    /// Response headers.
49    pub headers: Vec<(String, String)>,
50}
51
52/// Headers that must not be replayed onto the device's local request.
53///
54/// Hop-by-hop headers describe *this* connection, not the message; forwarding
55/// them makes the device answer about a connection it is not part of.
56const HOP_BY_HOP: &[&str] = &[
57    "connection",
58    "keep-alive",
59    "proxy-authenticate",
60    "proxy-authorization",
61    "te",
62    "trailer",
63    "transfer-encoding",
64    "upgrade",
65    "host",
66];
67
68/// Whether a header may be forwarded across the relay boundary.
69pub fn is_forwardable(name: &str) -> bool {
70    let name = name.to_ascii_lowercase();
71    !HOP_BY_HOP.contains(&name.as_str())
72}
73
74/// Strip the `/d/<device-id>` prefix, returning the device id and the remainder.
75///
76/// The remainder always starts with `/` so it can be appended to the device's
77/// local base URL unchanged.
78pub fn split_device_path(path: &str) -> Option<(&str, String)> {
79    let rest = path.strip_prefix("/d/")?;
80    let (device_id, tail) = match rest.find('/') {
81        Some(index) => (&rest[..index], &rest[index..]),
82        None => (rest, ""),
83    };
84    if device_id.is_empty() {
85        return None;
86    }
87    let tail = if tail.is_empty() { "/" } else { tail };
88    Some((device_id, tail.to_string()))
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    #[test]
96    fn device_path_is_split_from_the_remainder() {
97        assert_eq!(
98            split_device_path("/d/dev-1/api/v1/execute"),
99            Some(("dev-1", "/api/v1/execute".to_string()))
100        );
101    }
102
103    #[test]
104    fn a_bare_device_path_maps_to_the_root() {
105        assert_eq!(
106            split_device_path("/d/dev-1"),
107            Some(("dev-1", "/".to_string()))
108        );
109        assert_eq!(
110            split_device_path("/d/dev-1/"),
111            Some(("dev-1", "/".to_string()))
112        );
113    }
114
115    #[test]
116    fn non_device_paths_are_rejected() {
117        assert!(split_device_path("/health").is_none());
118        assert!(split_device_path("/d/").is_none());
119        assert!(split_device_path("/api/v1/execute").is_none());
120    }
121
122    #[test]
123    fn the_websocket_flag_defaults_to_false_for_older_peers() {
124        let json = r#"{"method":"GET","path":"/","headers":[]}"#;
125        let request: ProxyRequest = serde_json::from_str(json).unwrap();
126        assert!(!request.websocket);
127    }
128
129    #[test]
130    fn hop_by_hop_headers_are_not_forwarded() {
131        assert!(!is_forwardable("Connection"));
132        assert!(!is_forwardable("transfer-encoding"));
133        assert!(!is_forwardable("Host"));
134    }
135
136    #[test]
137    fn end_to_end_headers_are_forwarded() {
138        // Authorization in particular: the relay is a router, and the capability
139        // token has to reach the device unchanged.
140        assert!(is_forwardable("Authorization"));
141        assert!(is_forwardable("content-type"));
142    }
143
144    #[test]
145    fn proxy_messages_roundtrip() {
146        let request = ProxyRequest {
147            method: "POST".into(),
148            path: "/api/v1/execute".into(),
149            headers: vec![("authorization".into(), "Bearer st_x".into())],
150            websocket: false,
151        };
152        let json = serde_json::to_string(&request).unwrap();
153        assert_eq!(
154            serde_json::from_str::<ProxyRequest>(&json).unwrap(),
155            request
156        );
157
158        let response = ProxyResponse {
159            status: 200,
160            headers: vec![("content-type".into(), "application/json".into())],
161        };
162        let json = serde_json::to_string(&response).unwrap();
163        assert_eq!(
164            serde_json::from_str::<ProxyResponse>(&json).unwrap(),
165            response
166        );
167    }
168}