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}