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("/relay/v1/devices", get(devices_handler))
132 .route("/d/{*rest}", any(proxy_handler))
133 .with_state(state)
134}
135
136pub async fn serve_relay(config: RelayConfig) -> crate::Result<()> {
138 let bind = config.bind;
139 let state = RelayState::new(config);
140 let router = relay_router(state.clone());
141
142 let sweeper = state.devices().clone();
145 tokio::spawn(async move {
146 let mut ticker = tokio::time::interval(HEARTBEAT_TIMEOUT / 3);
147 loop {
148 ticker.tick().await;
149 for id in sweeper.evict_stale(HEARTBEAT_TIMEOUT) {
150 tracing::info!(target: "relay", device_id = %id, "device evicted (no heartbeat)");
151 }
152 }
153 });
154
155 tracing::info!("relay listening on {}", bind);
156
157 let listener = tokio::net::TcpListener::bind(bind)
158 .await
159 .map_err(ShellTunnelError::Io)?;
160 axum::serve(listener, router)
161 .await
162 .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())))?;
163 Ok(())
164}
165
166fn is_valid_device_name(name: &str) -> bool {
172 !name.is_empty()
173 && name.len() <= 64
174 && name
175 .chars()
176 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
177}
178
179fn observed_base(headers: &HeaderMap) -> Option<String> {
185 let host = headers
186 .get("x-forwarded-host")
187 .or_else(|| headers.get(axum::http::header::HOST))
188 .and_then(|value| value.to_str().ok())?;
189 if host.is_empty() {
190 return None;
191 }
192 let scheme = headers
193 .get("x-forwarded-proto")
194 .and_then(|value| value.to_str().ok())
195 .map(|proto| proto.split(',').next().unwrap_or(proto).trim().to_string())
196 .unwrap_or_else(|| "http".to_string());
197 Some(format!("{scheme}://{host}"))
198}
199
200async fn control_handler(
202 ws: WebSocketUpgrade,
203 State(state): State<RelayState>,
204 headers: HeaderMap,
205) -> impl IntoResponse {
206 let observed = observed_base(&headers);
207 ws.on_upgrade(move |socket| control_session(socket, state, observed))
208}
209
210async fn control_session(socket: WebSocket, state: RelayState, observed: Option<String>) {
212 let (mut sink, mut stream) = socket.split();
213
214 let first = match tokio::time::timeout(ENROLL_TIMEOUT, stream.next()).await {
217 Ok(Some(Ok(Message::Text(text)))) => text,
218 _ => return,
219 };
220
221 let enroll = match serde_json::from_str::<DeviceMessage>(&first) {
222 Ok(DeviceMessage::Enroll {
223 enroll_token,
224 version,
225 label,
226 device_name,
227 }) => (enroll_token, version, label, device_name),
228 _ => {
229 reject_and_close(
230 &mut sink,
231 reject::BAD_HANDSHAKE,
232 "expected an enroll message",
233 )
234 .await;
235 return;
236 }
237 };
238 let (enroll_token, version, label, device_name) = enroll;
239
240 if version != PROTOCOL_VERSION {
241 reject_and_close(
242 &mut sink,
243 reject::UNSUPPORTED_VERSION,
244 &format!("relay speaks protocol version {PROTOCOL_VERSION}"),
245 )
246 .await;
247 return;
248 }
249
250 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
251 tracing::debug!(target: "relay", "enrollment rejected: bad token");
253 reject_and_close(&mut sink, reject::BAD_TOKEN, "enrollment refused").await;
254 return;
255 }
256
257 let device_id = match device_name {
262 Some(name) if !is_valid_device_name(&name) => {
263 reject_and_close(
264 &mut sink,
265 reject::BAD_DEVICE_NAME,
266 "device names may use letters, digits, '-' and '_' (1-64 characters)",
267 )
268 .await;
269 return;
270 }
271 Some(name) => name,
277 None => generate_api_key(),
278 };
279 let public_url = state.config.public_url_for(&device_id, observed);
280 let registry::DeviceHandles {
281 device,
282 mut refill_rx,
283 } = state.devices.attach(&device_id, label.clone());
284 tracing::info!(
285 target: "relay",
286 device_id = %device_id,
287 label = label.as_deref().unwrap_or("-"),
288 "device attached"
289 );
290
291 let enrolled = RelayMessage::Enrolled {
292 device_id: device_id.clone(),
293 public_url,
294 };
295 if send_json(&mut sink, &enrolled).await.is_err() {
296 state.devices.detach(&device_id);
297 return;
298 }
299
300 let fill = RelayMessage::OpenData {
302 count: registry::POOL_TARGET,
303 };
304 if send_json(&mut sink, &fill).await.is_err() {
305 state.devices.detach(&device_id);
306 return;
307 }
308
309 loop {
312 tokio::select! {
313 incoming = stream.next() => {
314 let Some(Ok(message)) = incoming else { break };
315 match message {
316 Message::Text(text) => match serde_json::from_str::<DeviceMessage>(&text) {
317 Ok(DeviceMessage::Heartbeat) => {
318 device.touch();
319 if send_json(&mut sink, &RelayMessage::HeartbeatAck).await.is_err() {
320 break;
321 }
322 }
323 _ => continue,
327 },
328 Message::Close(_) => break,
329 _ => continue,
330 }
331 }
332 refill = refill_rx.recv() => {
333 if refill.is_none() {
334 break;
335 }
336 if send_json(&mut sink, &RelayMessage::OpenData { count: 1 }).await.is_err() {
337 break;
338 }
339 }
340 }
341 }
342
343 state.devices.detach(&device_id);
344 tracing::info!(target: "relay", device_id = %device_id, "device detached");
345}
346
347async fn devices_handler(State(state): State<RelayState>, headers: HeaderMap) -> Response {
353 let presented = headers
354 .get(axum::http::header::AUTHORIZATION)
355 .and_then(|value| value.to_str().ok())
356 .and_then(|value| value.strip_prefix("Bearer "))
357 .unwrap_or("");
358 if !constant_time_eq(presented, &state.config.enroll_token) {
359 return StatusCode::UNAUTHORIZED.into_response();
360 }
361
362 let base = state.config.public_base_or(observed_base(&headers));
363 let devices: Vec<_> = state
364 .devices
365 .list()
366 .into_iter()
367 .map(|device| {
368 let url = format!("{}/d/{}", base, device.id);
369 serde_json::json!({
370 "id": device.id,
371 "label": device.label,
372 "attached_secs": device.attached_secs,
373 "last_seen_secs": device.last_seen_secs,
374 "public_url": url,
375 })
376 })
377 .collect();
378
379 axum::Json(serde_json::json!({ "devices": devices })).into_response()
380}
381
382async fn data_handler(ws: WebSocketUpgrade, State(state): State<RelayState>) -> Response {
389 ws.on_upgrade(move |socket| attach_data_connection(socket, state))
390}
391
392async fn attach_data_connection(mut socket: WebSocket, state: RelayState) {
394 let first = tokio::time::timeout(ENROLL_TIMEOUT, socket.recv()).await;
395 let Ok(Some(Ok(Message::Text(text)))) = first else {
396 let _ = socket.close().await;
397 return;
398 };
399
400 let Ok(DeviceMessage::Attach {
401 device_id,
402 enroll_token,
403 }) = serde_json::from_str::<DeviceMessage>(&text)
404 else {
405 let _ = socket.close().await;
406 return;
407 };
408
409 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
410 tracing::debug!(target: "relay", "data connection rejected: bad token");
411 let _ = socket.close().await;
412 return;
413 }
414
415 let Some(device) = state.devices.get(&device_id) else {
416 let _ = socket.close().await;
417 return;
418 };
419
420 if let Some(mut extra) = device.offer(socket).await {
423 let _ = extra.close().await;
424 }
425}
426
427async fn proxy_handler(State(state): State<RelayState>, request: Request) -> Response {
429 let path_and_query = request
430 .uri()
431 .path_and_query()
432 .map(|p| p.as_str().to_string())
433 .unwrap_or_else(|| request.uri().path().to_string());
434
435 let Some((device_id, tail)) = split_device_path(&path_and_query) else {
436 return StatusCode::NOT_FOUND.into_response();
437 };
438
439 let Some(device) = state.devices.get(device_id) else {
440 return (StatusCode::BAD_GATEWAY, "device is not connected").into_response();
443 };
444
445 let method = request.method().to_string();
446 let headers: Vec<(String, String)> = request
447 .headers()
448 .iter()
449 .filter(|(name, _)| is_forwardable(name.as_str()))
450 .filter_map(|(name, value)| {
451 value
452 .to_str()
453 .ok()
454 .map(|v| (name.as_str().to_string(), v.to_string()))
455 })
456 .collect();
457
458 if is_websocket_upgrade(request.headers()) {
464 let (mut parts, _) = request.into_parts();
465 let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
466 Ok(upgrade) => upgrade,
467 Err(rejection) => return rejection.into_response(),
468 };
469 let proxied = ProxyRequest {
470 method,
471 path: tail,
472 headers,
473 websocket: true,
474 };
475 return upgrade.on_upgrade(move |client| pipe_websocket(client, device, proxied));
476 }
477
478 let body = match axum::body::to_bytes(request.into_body(), MAX_BODY).await {
479 Ok(body) => body,
480 Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
481 };
482
483 let Some(conn) = device.take(POOL_WAIT).await else {
484 return (
487 StatusCode::SERVICE_UNAVAILABLE,
488 [("retry-after", "1")],
489 "no data connection available",
490 )
491 .into_response();
492 };
493
494 match tokio::time::timeout(
495 REQUEST_TIMEOUT,
496 forward(
497 conn,
498 ProxyRequest {
499 method,
500 path: tail,
501 headers,
502 websocket: false,
503 },
504 body,
505 ),
506 )
507 .await
508 {
509 Ok(Ok(response)) => response,
510 Ok(Err(reason)) => {
511 tracing::debug!(target: "relay", device_id = %device.id, reason, "proxy failed");
512 (StatusCode::BAD_GATEWAY, "device did not answer").into_response()
513 }
514 Err(_) => (StatusCode::GATEWAY_TIMEOUT, "device timed out").into_response(),
515 }
516}
517
518fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
520 let header_contains = |name: axum::http::HeaderName, needle: &str| {
521 headers
522 .get(name)
523 .and_then(|value| value.to_str().ok())
524 .is_some_and(|value| value.to_ascii_lowercase().contains(needle))
525 };
526 header_contains(axum::http::header::UPGRADE, "websocket")
527 && header_contains(axum::http::header::CONNECTION, "upgrade")
528}
529
530async fn pipe_websocket(mut client: WebSocket, device: Arc<Device>, request: ProxyRequest) {
536 let Some(mut conn) = device.take(POOL_WAIT).await else {
537 tracing::debug!(target: "relay", device_id = %device.id, "no data connection for websocket");
538 let _ = client.close().await;
539 return;
540 };
541
542 let Ok(header) = serde_json::to_string(&request) else {
543 let _ = client.close().await;
544 return;
545 };
546 if conn.send(Message::Text(header.into())).await.is_err() {
547 let _ = client.close().await;
548 return;
549 }
550
551 let switched = matches!(
554 conn.recv().await,
555 Some(Ok(Message::Text(ref text)))
556 if serde_json::from_str::<ProxyResponse>(text)
557 .map(|response| response.status == 101)
558 .unwrap_or(false)
559 );
560 if !switched {
561 let _ = client.close().await;
562 let _ = conn.close().await;
563 return;
564 }
565
566 loop {
569 tokio::select! {
570 from_client = client.recv() => {
571 match from_client {
572 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
573 Some(Ok(message)) => {
574 if conn.send(message).await.is_err() {
575 break;
576 }
577 }
578 }
579 }
580 from_device = conn.recv() => {
581 match from_device {
582 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
583 Some(Ok(message)) => {
584 if client.send(message).await.is_err() {
585 break;
586 }
587 }
588 }
589 }
590 }
591 }
592
593 let _ = client.close().await;
594 let _ = conn.close().await;
595}
596
597const MAX_BODY: usize = 8 * 1024 * 1024;
599
600async fn forward(
605 mut conn: WebSocket,
606 request: ProxyRequest,
607 body: Bytes,
608) -> Result<Response, &'static str> {
609 let header = serde_json::to_string(&request).map_err(|_| "request-encode")?;
610 conn.send(Message::Text(header.into()))
611 .await
612 .map_err(|_| "request-header-send")?;
613 conn.send(Message::Binary(body))
614 .await
615 .map_err(|_| "request-body-send")?;
616
617 let head: ProxyResponse = loop {
618 match conn.recv().await {
619 Some(Ok(Message::Text(text))) => {
620 break serde_json::from_str(&text).map_err(|_| "response-decode")?
621 }
622 Some(Ok(_)) => continue,
623 _ => return Err("response-header-missing"),
624 }
625 };
626
627 let mut body = Vec::new();
628 while let Some(Ok(message)) = conn.recv().await {
629 match message {
630 Message::Binary(chunk) => body.extend_from_slice(&chunk),
631 Message::Close(_) => break,
632 _ => continue,
633 }
634 }
635
636 let mut response = Response::builder().status(head.status);
637 for (name, value) in head.headers {
638 if is_forwardable(&name) {
639 response = response.header(name, value);
640 }
641 }
642 response
643 .body(axum::body::Body::from(body))
644 .map_err(|_| "response-build")
645}
646
647async fn reject_and_close<S>(sink: &mut S, code: &str, message: &str)
649where
650 S: SinkExt<Message> + Unpin,
651{
652 let rejected = RelayMessage::Rejected {
653 code: code.to_string(),
654 message: message.to_string(),
655 };
656 let _ = send_json(sink, &rejected).await;
657 let _ = sink.close().await;
658}
659
660async fn send_json<S, T>(sink: &mut S, message: &T) -> Result<(), ()>
662where
663 S: SinkExt<Message> + Unpin,
664 T: serde::Serialize,
665{
666 let json = serde_json::to_string(message).map_err(|_| ())?;
667 sink.send(Message::Text(json.into())).await.map_err(|_| ())
668}
669
670fn constant_time_eq(a: &str, b: &str) -> bool {
676 let (a, b) = (a.as_bytes(), b.as_bytes());
677 if a.len() != b.len() {
678 return false;
679 }
680 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
681}
682
683#[cfg(test)]
684mod tests {
685 use super::*;
686
687 fn config() -> RelayConfig {
688 RelayConfig::new("127.0.0.1:0".parse().unwrap(), "secret")
689 }
690
691 #[test]
692 fn public_url_uses_the_device_path_prefix() {
693 let config = config().with_public_base("https://relay.example.com/");
694 assert_eq!(
695 config.public_url_for("dev-1", None),
696 "https://relay.example.com/d/dev-1"
697 );
698 }
699
700 #[test]
701 fn public_base_defaults_to_the_bind_address() {
702 let config = RelayConfig::new("127.0.0.1:8443".parse().unwrap(), "secret");
703 assert_eq!(
704 config.public_url_for("d", None),
705 "http://127.0.0.1:8443/d/d"
706 );
707 }
708
709 #[test]
710 fn an_observed_address_is_used_when_the_operator_configured_none() {
711 let config = config();
712 assert_eq!(
713 config.public_url_for("dev-1", Some("https://relay.example.com".into())),
714 "https://relay.example.com/d/dev-1"
715 );
716 }
717
718 #[test]
719 fn a_configured_base_wins_over_what_the_connection_observed() {
720 let config = config().with_public_base("https://canonical.example");
721 assert_eq!(
722 config.public_url_for("dev-1", Some("https://whatever.invalid".into())),
723 "https://canonical.example/d/dev-1"
724 );
725 }
726
727 #[test]
728 fn the_forwarded_scheme_and_host_are_preferred_over_the_direct_host() {
729 let mut headers = HeaderMap::new();
730 headers.insert(axum::http::header::HOST, "127.0.0.1:8443".parse().unwrap());
731 assert_eq!(
732 observed_base(&headers).as_deref(),
733 Some("http://127.0.0.1:8443")
734 );
735
736 headers.insert("x-forwarded-proto", "https".parse().unwrap());
737 headers.insert("x-forwarded-host", "relay.example.com".parse().unwrap());
738 assert_eq!(
739 observed_base(&headers).as_deref(),
740 Some("https://relay.example.com")
741 );
742 }
743
744 #[test]
745 fn a_proxy_chain_scheme_takes_the_first_entry() {
746 let mut headers = HeaderMap::new();
747 headers.insert(
748 axum::http::header::HOST,
749 "relay.example.com".parse().unwrap(),
750 );
751 headers.insert("x-forwarded-proto", "https, http".parse().unwrap());
752 assert_eq!(
753 observed_base(&headers).as_deref(),
754 Some("https://relay.example.com")
755 );
756 }
757
758 #[test]
759 fn no_host_header_means_nothing_observed() {
760 assert!(observed_base(&HeaderMap::new()).is_none());
761 }
762
763 #[test]
764 fn device_names_must_be_url_path_safe() {
765 assert!(is_valid_device_name("build-box"));
766 assert!(is_valid_device_name("laptop_2"));
767 assert!(is_valid_device_name("a"));
768
769 assert!(!is_valid_device_name(""));
770 assert!(!is_valid_device_name("has space"));
771 assert!(!is_valid_device_name("../escape"));
772 assert!(!is_valid_device_name("slash/inside"));
773 assert!(!is_valid_device_name("querylike?x=1"));
774 assert!(!is_valid_device_name(&"x".repeat(65)));
775 }
776
777 #[test]
778 fn constant_time_eq_matches_equality() {
779 assert!(constant_time_eq("abc", "abc"));
780 assert!(!constant_time_eq("abc", "abd"));
781 assert!(!constant_time_eq("abc", "ab"));
782 assert!(constant_time_eq("", ""));
783 }
784}