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.
56///
57/// `expect` is here for the same reason, and it is not merely tidy — leaving it
58/// in produced a 500 for every body-carrying request a normal client made.
59/// The relay buffers the entire body before it forwards anything (see
60/// `MAX_BODY`), so by the time the device sees the request the expectation has
61/// already been met: there is nothing left for the device to decide. Replaying
62/// `Expect: 100-continue` onto the device's own local HTTP call instead made
63/// that server answer with an interim `100 Continue`, which the device
64/// reported back as the response, which the relay then tried to return as a
65/// final status — and hyper cannot write a 1xx as a final response, so it
66/// substituted an empty `500`.
67///
68/// The request itself always succeeded. The device ran the command or wrote the
69/// chunk and answered 200; only the status the caller saw was wrong, which is
70/// the worst way for this to fail — a non-idempotent `execute` reported as
71/// failed after it had already run. `curl` adds this header automatically for
72/// bodies over about a kilobyte, so every upload chunk tripped it while small
73/// `execute` payloads did not, which is why it stayed hidden until the file
74/// API made large bodies ordinary.
75const HOP_BY_HOP: &[&str] = &[
76    "connection",
77    "keep-alive",
78    "proxy-authenticate",
79    "proxy-authorization",
80    "te",
81    "trailer",
82    "transfer-encoding",
83    "upgrade",
84    "host",
85    "expect",
86];
87
88/// Whether a header may be forwarded across the relay boundary.
89pub fn is_forwardable(name: &str) -> bool {
90    let name = name.to_ascii_lowercase();
91    !HOP_BY_HOP.contains(&name.as_str())
92}
93
94/// Strip the `/d/<device-id>` prefix, returning the device id and the remainder.
95///
96/// The remainder always starts with `/` so it can be appended to the device's
97/// local base URL unchanged.
98pub fn split_device_path(path: &str) -> Option<(&str, String)> {
99    let rest = path.strip_prefix("/d/")?;
100    let (device_id, tail) = match rest.find('/') {
101        Some(index) => (&rest[..index], &rest[index..]),
102        None => (rest, ""),
103    };
104    if device_id.is_empty() {
105        return None;
106    }
107    let tail = if tail.is_empty() { "/" } else { tail };
108    Some((device_id, tail.to_string()))
109}
110
111#[cfg(test)]
112mod tests {
113    use super::*;
114
115    #[test]
116    fn device_path_is_split_from_the_remainder() {
117        assert_eq!(
118            split_device_path("/d/dev-1/api/v1/execute"),
119            Some(("dev-1", "/api/v1/execute".to_string()))
120        );
121    }
122
123    #[test]
124    fn a_bare_device_path_maps_to_the_root() {
125        assert_eq!(
126            split_device_path("/d/dev-1"),
127            Some(("dev-1", "/".to_string()))
128        );
129        assert_eq!(
130            split_device_path("/d/dev-1/"),
131            Some(("dev-1", "/".to_string()))
132        );
133    }
134
135    #[test]
136    fn non_device_paths_are_rejected() {
137        assert!(split_device_path("/health").is_none());
138        assert!(split_device_path("/d/").is_none());
139        assert!(split_device_path("/api/v1/execute").is_none());
140    }
141
142    #[test]
143    fn the_websocket_flag_defaults_to_false_for_older_peers() {
144        let json = r#"{"method":"GET","path":"/","headers":[]}"#;
145        let request: ProxyRequest = serde_json::from_str(json).unwrap();
146        assert!(!request.websocket);
147    }
148
149    #[test]
150    fn hop_by_hop_headers_are_not_forwarded() {
151        assert!(!is_forwardable("Connection"));
152        assert!(!is_forwardable("transfer-encoding"));
153        assert!(!is_forwardable("Host"));
154    }
155
156    #[test]
157    fn end_to_end_headers_are_forwarded() {
158        // Authorization in particular: the relay is a router, and the capability
159        // token has to reach the device unchanged.
160        assert!(is_forwardable("Authorization"));
161        assert!(is_forwardable("content-type"));
162    }
163
164    #[test]
165    fn proxy_messages_roundtrip() {
166        let request = ProxyRequest {
167            method: "POST".into(),
168            path: "/api/v1/execute".into(),
169            headers: vec![("authorization".into(), "Bearer st_x".into())],
170            websocket: false,
171        };
172        let json = serde_json::to_string(&request).unwrap();
173        assert_eq!(
174            serde_json::from_str::<ProxyRequest>(&json).unwrap(),
175            request
176        );
177
178        let response = ProxyResponse {
179            status: 200,
180            headers: vec![("content-type".into(), "application/json".into())],
181        };
182        let json = serde_json::to_string(&response).unwrap();
183        assert_eq!(
184            serde_json::from_str::<ProxyResponse>(&json).unwrap(),
185            response
186        );
187    }
188}