1#[cfg(feature = "relay-client")]
16pub mod client;
17pub mod protocol;
18pub mod proxy;
19pub mod registry;
20
21use std::net::SocketAddr;
22use std::sync::Arc;
23use std::time::Duration;
24
25use axum::{
26 body::Bytes,
27 extract::{
28 ws::{Message, WebSocket, WebSocketUpgrade},
29 FromRequestParts, Request, State,
30 },
31 http::{HeaderMap, StatusCode},
32 response::{IntoResponse, Response},
33 routing::{any, get},
34 Router,
35};
36use futures_util::{SinkExt, StreamExt};
37
38use crate::error::ShellTunnelError;
39use crate::security::generate_api_key;
40use protocol::{reject, DeviceMessage, RelayMessage, PROTOCOL_VERSION};
41use proxy::{
42 is_forwardable, split_device_path, ProxyRequest, ProxyResponse, POOL_WAIT, REQUEST_TIMEOUT,
43};
44use registry::{Device, DeviceRegistry};
45
46pub use registry::{DeviceRegistry as Registry, POOL_TARGET};
47
48pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(90);
50
51const ENROLL_TIMEOUT: Duration = Duration::from_secs(10);
53
54#[derive(Debug, Clone)]
56pub struct RelayConfig {
57 pub bind: SocketAddr,
59 pub enroll_token: String,
61 pub public_base: Option<String>,
67}
68
69impl RelayConfig {
70 pub fn new(bind: SocketAddr, enroll_token: impl Into<String>) -> Self {
72 Self {
73 bind,
74 enroll_token: enroll_token.into(),
75 public_base: None,
76 }
77 }
78
79 pub fn with_public_base(mut self, base: impl Into<String>) -> Self {
81 self.public_base = Some(base.into().trim_end_matches('/').to_string());
82 self
83 }
84
85 pub fn public_base_or(&self, observed: Option<String>) -> String {
91 self.public_base
92 .clone()
93 .or(observed)
94 .unwrap_or_else(|| format!("http://{}", self.bind))
95 }
96
97 pub fn public_url_for(&self, device_id: &str, observed: Option<String>) -> String {
99 format!("{}/d/{}", self.public_base_or(observed), device_id)
100 }
101}
102
103#[derive(Debug, Clone)]
105pub struct RelayState {
106 config: RelayConfig,
107 devices: DeviceRegistry,
108}
109
110impl RelayState {
111 pub fn new(config: RelayConfig) -> Self {
113 Self {
114 config,
115 devices: DeviceRegistry::new(),
116 }
117 }
118
119 pub fn devices(&self) -> &DeviceRegistry {
121 &self.devices
122 }
123}
124
125pub fn relay_router(state: RelayState) -> Router {
127 Router::new()
128 .route("/health", get(|| async { "OK" }))
129 .route("/relay/v1/control", get(control_handler))
130 .route("/relay/v1/data", get(data_handler))
131 .route("/d/{*rest}", any(proxy_handler))
132 .with_state(state)
133}
134
135pub async fn serve_relay(config: RelayConfig) -> crate::Result<()> {
137 let bind = config.bind;
138 let state = RelayState::new(config);
139 let router = relay_router(state.clone());
140
141 let sweeper = state.devices().clone();
144 tokio::spawn(async move {
145 let mut ticker = tokio::time::interval(HEARTBEAT_TIMEOUT / 3);
146 loop {
147 ticker.tick().await;
148 for id in sweeper.evict_stale(HEARTBEAT_TIMEOUT) {
149 tracing::info!(target: "relay", device_id = %id, "device evicted (no heartbeat)");
150 }
151 }
152 });
153
154 tracing::info!("relay listening on {}", bind);
155
156 let listener = tokio::net::TcpListener::bind(bind)
157 .await
158 .map_err(ShellTunnelError::Io)?;
159 axum::serve(listener, router)
160 .await
161 .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())))?;
162 Ok(())
163}
164
165fn observed_base(headers: &HeaderMap) -> Option<String> {
171 let host = headers
172 .get("x-forwarded-host")
173 .or_else(|| headers.get(axum::http::header::HOST))
174 .and_then(|value| value.to_str().ok())?;
175 if host.is_empty() {
176 return None;
177 }
178 let scheme = headers
179 .get("x-forwarded-proto")
180 .and_then(|value| value.to_str().ok())
181 .map(|proto| proto.split(',').next().unwrap_or(proto).trim().to_string())
182 .unwrap_or_else(|| "http".to_string());
183 Some(format!("{scheme}://{host}"))
184}
185
186async fn control_handler(
188 ws: WebSocketUpgrade,
189 State(state): State<RelayState>,
190 headers: HeaderMap,
191) -> impl IntoResponse {
192 let observed = observed_base(&headers);
193 ws.on_upgrade(move |socket| control_session(socket, state, observed))
194}
195
196async fn control_session(socket: WebSocket, state: RelayState, observed: Option<String>) {
198 let (mut sink, mut stream) = socket.split();
199
200 let first = match tokio::time::timeout(ENROLL_TIMEOUT, stream.next()).await {
203 Ok(Some(Ok(Message::Text(text)))) => text,
204 _ => return,
205 };
206
207 let enroll = match serde_json::from_str::<DeviceMessage>(&first) {
208 Ok(DeviceMessage::Enroll {
209 enroll_token,
210 version,
211 label,
212 }) => (enroll_token, version, label),
213 _ => {
214 reject_and_close(
215 &mut sink,
216 reject::BAD_HANDSHAKE,
217 "expected an enroll message",
218 )
219 .await;
220 return;
221 }
222 };
223 let (enroll_token, version, label) = enroll;
224
225 if version != PROTOCOL_VERSION {
226 reject_and_close(
227 &mut sink,
228 reject::UNSUPPORTED_VERSION,
229 &format!("relay speaks protocol version {PROTOCOL_VERSION}"),
230 )
231 .await;
232 return;
233 }
234
235 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
236 tracing::debug!(target: "relay", "enrollment rejected: bad token");
238 reject_and_close(&mut sink, reject::BAD_TOKEN, "enrollment refused").await;
239 return;
240 }
241
242 let device_id = generate_api_key();
245 let public_url = state.config.public_url_for(&device_id, observed);
246 let registry::DeviceHandles {
247 device,
248 mut refill_rx,
249 } = state.devices.attach(&device_id, label.clone());
250 tracing::info!(
251 target: "relay",
252 device_id = %device_id,
253 label = label.as_deref().unwrap_or("-"),
254 "device attached"
255 );
256
257 let enrolled = RelayMessage::Enrolled {
258 device_id: device_id.clone(),
259 public_url,
260 };
261 if send_json(&mut sink, &enrolled).await.is_err() {
262 state.devices.detach(&device_id);
263 return;
264 }
265
266 let fill = RelayMessage::OpenData {
268 count: registry::POOL_TARGET,
269 };
270 if send_json(&mut sink, &fill).await.is_err() {
271 state.devices.detach(&device_id);
272 return;
273 }
274
275 loop {
278 tokio::select! {
279 incoming = stream.next() => {
280 let Some(Ok(message)) = incoming else { break };
281 match message {
282 Message::Text(text) => match serde_json::from_str::<DeviceMessage>(&text) {
283 Ok(DeviceMessage::Heartbeat) => {
284 device.touch();
285 if send_json(&mut sink, &RelayMessage::HeartbeatAck).await.is_err() {
286 break;
287 }
288 }
289 _ => continue,
293 },
294 Message::Close(_) => break,
295 _ => continue,
296 }
297 }
298 refill = refill_rx.recv() => {
299 if refill.is_none() {
300 break;
301 }
302 if send_json(&mut sink, &RelayMessage::OpenData { count: 1 }).await.is_err() {
303 break;
304 }
305 }
306 }
307 }
308
309 state.devices.detach(&device_id);
310 tracing::info!(target: "relay", device_id = %device_id, "device detached");
311}
312
313async fn data_handler(ws: WebSocketUpgrade, State(state): State<RelayState>) -> Response {
320 ws.on_upgrade(move |socket| attach_data_connection(socket, state))
321}
322
323async fn attach_data_connection(mut socket: WebSocket, state: RelayState) {
325 let first = tokio::time::timeout(ENROLL_TIMEOUT, socket.recv()).await;
326 let Ok(Some(Ok(Message::Text(text)))) = first else {
327 let _ = socket.close().await;
328 return;
329 };
330
331 let Ok(DeviceMessage::Attach {
332 device_id,
333 enroll_token,
334 }) = serde_json::from_str::<DeviceMessage>(&text)
335 else {
336 let _ = socket.close().await;
337 return;
338 };
339
340 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
341 tracing::debug!(target: "relay", "data connection rejected: bad token");
342 let _ = socket.close().await;
343 return;
344 }
345
346 let Some(device) = state.devices.get(&device_id) else {
347 let _ = socket.close().await;
348 return;
349 };
350
351 if let Some(mut extra) = device.offer(socket).await {
354 let _ = extra.close().await;
355 }
356}
357
358async fn proxy_handler(State(state): State<RelayState>, request: Request) -> Response {
360 let path_and_query = request
361 .uri()
362 .path_and_query()
363 .map(|p| p.as_str().to_string())
364 .unwrap_or_else(|| request.uri().path().to_string());
365
366 let Some((device_id, tail)) = split_device_path(&path_and_query) else {
367 return StatusCode::NOT_FOUND.into_response();
368 };
369
370 let Some(device) = state.devices.get(device_id) else {
371 return (StatusCode::BAD_GATEWAY, "device is not connected").into_response();
374 };
375
376 let method = request.method().to_string();
377 let headers: Vec<(String, String)> = request
378 .headers()
379 .iter()
380 .filter(|(name, _)| is_forwardable(name.as_str()))
381 .filter_map(|(name, value)| {
382 value
383 .to_str()
384 .ok()
385 .map(|v| (name.as_str().to_string(), v.to_string()))
386 })
387 .collect();
388
389 if is_websocket_upgrade(request.headers()) {
395 let (mut parts, _) = request.into_parts();
396 let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
397 Ok(upgrade) => upgrade,
398 Err(rejection) => return rejection.into_response(),
399 };
400 let proxied = ProxyRequest {
401 method,
402 path: tail,
403 headers,
404 websocket: true,
405 };
406 return upgrade.on_upgrade(move |client| pipe_websocket(client, device, proxied));
407 }
408
409 let body = match axum::body::to_bytes(request.into_body(), MAX_BODY).await {
410 Ok(body) => body,
411 Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
412 };
413
414 let Some(conn) = device.take(POOL_WAIT).await else {
415 return (
418 StatusCode::SERVICE_UNAVAILABLE,
419 [("retry-after", "1")],
420 "no data connection available",
421 )
422 .into_response();
423 };
424
425 match tokio::time::timeout(
426 REQUEST_TIMEOUT,
427 forward(
428 conn,
429 ProxyRequest {
430 method,
431 path: tail,
432 headers,
433 websocket: false,
434 },
435 body,
436 ),
437 )
438 .await
439 {
440 Ok(Ok(response)) => response,
441 Ok(Err(reason)) => {
442 tracing::debug!(target: "relay", device_id = %device.id, reason, "proxy failed");
443 (StatusCode::BAD_GATEWAY, "device did not answer").into_response()
444 }
445 Err(_) => (StatusCode::GATEWAY_TIMEOUT, "device timed out").into_response(),
446 }
447}
448
449fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
451 let header_contains = |name: axum::http::HeaderName, needle: &str| {
452 headers
453 .get(name)
454 .and_then(|value| value.to_str().ok())
455 .is_some_and(|value| value.to_ascii_lowercase().contains(needle))
456 };
457 header_contains(axum::http::header::UPGRADE, "websocket")
458 && header_contains(axum::http::header::CONNECTION, "upgrade")
459}
460
461async fn pipe_websocket(mut client: WebSocket, device: Arc<Device>, request: ProxyRequest) {
467 let Some(mut conn) = device.take(POOL_WAIT).await else {
468 tracing::debug!(target: "relay", device_id = %device.id, "no data connection for websocket");
469 let _ = client.close().await;
470 return;
471 };
472
473 let Ok(header) = serde_json::to_string(&request) else {
474 let _ = client.close().await;
475 return;
476 };
477 if conn.send(Message::Text(header.into())).await.is_err() {
478 let _ = client.close().await;
479 return;
480 }
481
482 let switched = matches!(
485 conn.recv().await,
486 Some(Ok(Message::Text(ref text)))
487 if serde_json::from_str::<ProxyResponse>(text)
488 .map(|response| response.status == 101)
489 .unwrap_or(false)
490 );
491 if !switched {
492 let _ = client.close().await;
493 let _ = conn.close().await;
494 return;
495 }
496
497 loop {
500 tokio::select! {
501 from_client = client.recv() => {
502 match from_client {
503 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
504 Some(Ok(message)) => {
505 if conn.send(message).await.is_err() {
506 break;
507 }
508 }
509 }
510 }
511 from_device = conn.recv() => {
512 match from_device {
513 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
514 Some(Ok(message)) => {
515 if client.send(message).await.is_err() {
516 break;
517 }
518 }
519 }
520 }
521 }
522 }
523
524 let _ = client.close().await;
525 let _ = conn.close().await;
526}
527
528const MAX_BODY: usize = 8 * 1024 * 1024;
530
531async fn forward(
536 mut conn: WebSocket,
537 request: ProxyRequest,
538 body: Bytes,
539) -> Result<Response, &'static str> {
540 let header = serde_json::to_string(&request).map_err(|_| "request-encode")?;
541 conn.send(Message::Text(header.into()))
542 .await
543 .map_err(|_| "request-header-send")?;
544 conn.send(Message::Binary(body))
545 .await
546 .map_err(|_| "request-body-send")?;
547
548 let head: ProxyResponse = loop {
549 match conn.recv().await {
550 Some(Ok(Message::Text(text))) => {
551 break serde_json::from_str(&text).map_err(|_| "response-decode")?
552 }
553 Some(Ok(_)) => continue,
554 _ => return Err("response-header-missing"),
555 }
556 };
557
558 let mut body = Vec::new();
559 while let Some(Ok(message)) = conn.recv().await {
560 match message {
561 Message::Binary(chunk) => body.extend_from_slice(&chunk),
562 Message::Close(_) => break,
563 _ => continue,
564 }
565 }
566
567 let mut response = Response::builder().status(head.status);
568 for (name, value) in head.headers {
569 if is_forwardable(&name) {
570 response = response.header(name, value);
571 }
572 }
573 response
574 .body(axum::body::Body::from(body))
575 .map_err(|_| "response-build")
576}
577
578async fn reject_and_close<S>(sink: &mut S, code: &str, message: &str)
580where
581 S: SinkExt<Message> + Unpin,
582{
583 let rejected = RelayMessage::Rejected {
584 code: code.to_string(),
585 message: message.to_string(),
586 };
587 let _ = send_json(sink, &rejected).await;
588 let _ = sink.close().await;
589}
590
591async fn send_json<S, T>(sink: &mut S, message: &T) -> Result<(), ()>
593where
594 S: SinkExt<Message> + Unpin,
595 T: serde::Serialize,
596{
597 let json = serde_json::to_string(message).map_err(|_| ())?;
598 sink.send(Message::Text(json.into())).await.map_err(|_| ())
599}
600
601fn constant_time_eq(a: &str, b: &str) -> bool {
607 let (a, b) = (a.as_bytes(), b.as_bytes());
608 if a.len() != b.len() {
609 return false;
610 }
611 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
612}
613
614#[cfg(test)]
615mod tests {
616 use super::*;
617
618 fn config() -> RelayConfig {
619 RelayConfig::new("127.0.0.1:0".parse().unwrap(), "secret")
620 }
621
622 #[test]
623 fn public_url_uses_the_device_path_prefix() {
624 let config = config().with_public_base("https://relay.example.com/");
625 assert_eq!(
626 config.public_url_for("dev-1", None),
627 "https://relay.example.com/d/dev-1"
628 );
629 }
630
631 #[test]
632 fn public_base_defaults_to_the_bind_address() {
633 let config = RelayConfig::new("127.0.0.1:8443".parse().unwrap(), "secret");
634 assert_eq!(
635 config.public_url_for("d", None),
636 "http://127.0.0.1:8443/d/d"
637 );
638 }
639
640 #[test]
641 fn an_observed_address_is_used_when_the_operator_configured_none() {
642 let config = config();
643 assert_eq!(
644 config.public_url_for("dev-1", Some("https://relay.example.com".into())),
645 "https://relay.example.com/d/dev-1"
646 );
647 }
648
649 #[test]
650 fn a_configured_base_wins_over_what_the_connection_observed() {
651 let config = config().with_public_base("https://canonical.example");
652 assert_eq!(
653 config.public_url_for("dev-1", Some("https://whatever.invalid".into())),
654 "https://canonical.example/d/dev-1"
655 );
656 }
657
658 #[test]
659 fn the_forwarded_scheme_and_host_are_preferred_over_the_direct_host() {
660 let mut headers = HeaderMap::new();
661 headers.insert(axum::http::header::HOST, "127.0.0.1:8443".parse().unwrap());
662 assert_eq!(
663 observed_base(&headers).as_deref(),
664 Some("http://127.0.0.1:8443")
665 );
666
667 headers.insert("x-forwarded-proto", "https".parse().unwrap());
668 headers.insert("x-forwarded-host", "relay.example.com".parse().unwrap());
669 assert_eq!(
670 observed_base(&headers).as_deref(),
671 Some("https://relay.example.com")
672 );
673 }
674
675 #[test]
676 fn a_proxy_chain_scheme_takes_the_first_entry() {
677 let mut headers = HeaderMap::new();
678 headers.insert(
679 axum::http::header::HOST,
680 "relay.example.com".parse().unwrap(),
681 );
682 headers.insert("x-forwarded-proto", "https, http".parse().unwrap());
683 assert_eq!(
684 observed_base(&headers).as_deref(),
685 Some("https://relay.example.com")
686 );
687 }
688
689 #[test]
690 fn no_host_header_means_nothing_observed() {
691 assert!(observed_base(&HeaderMap::new()).is_none());
692 }
693
694 #[test]
695 fn constant_time_eq_matches_equality() {
696 assert!(constant_time_eq("abc", "abc"));
697 assert!(!constant_time_eq("abc", "abd"));
698 assert!(!constant_time_eq("abc", "ab"));
699 assert!(constant_time_eq("", ""));
700 }
701}