Skip to main content

shell_tunnel/relay/
mod.rs

1//! Self-hosted relay: reaching a device that dialled out to you.
2//!
3//! The relay is the alternative to a third-party tunnel. A device opens one
4//! outbound WebSocket to it — no inbound port, no NAT configuration — and the
5//! relay routes public traffic back down that connection.
6//!
7//! It runs from the same binary (`shell-tunnel relay`), so an operator never
8//! has to match versions between two programs.
9//!
10//! What the relay deliberately does *not* do: interpret capability tokens.
11//! Enrollment decides which devices may attach; the capability token in each
12//! proxied request stays end-to-end between client and device. The relay is a
13//! router, not a second security boundary.
14
15#[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
48/// How long a device may go without a heartbeat before it is considered gone.
49pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(90);
50
51/// How long to wait for the enrollment frame before dropping a connection.
52const ENROLL_TIMEOUT: Duration = Duration::from_secs(10);
53
54/// Relay server settings.
55#[derive(Debug, Clone)]
56pub struct RelayConfig {
57    /// Address to listen on.
58    pub bind: SocketAddr,
59    /// Secret a device must present to attach.
60    pub enroll_token: String,
61    /// Public base URL of this relay, when the operator states it explicitly.
62    ///
63    /// Left unset, the relay derives it from each connection's `Host` (and
64    /// `X-Forwarded-*`) headers, so a relay behind TLS termination still tells
65    /// devices an address that actually works.
66    pub public_base: Option<String>,
67}
68
69impl RelayConfig {
70    /// Create a configuration with the given bind address and token.
71    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    /// Set the public base URL advertised to devices.
80    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    /// The base URL to advertise, preferring what the operator configured.
86    ///
87    /// `observed` is what the connection itself says this relay is reachable at.
88    /// Falling back to the bind address is a last resort — it is right only when
89    /// nothing is in front of the relay.
90    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    /// Public URL that routes to `device_id`.
98    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/// Shared relay state.
104#[derive(Debug, Clone)]
105pub struct RelayState {
106    config: RelayConfig,
107    devices: DeviceRegistry,
108}
109
110impl RelayState {
111    /// Create state for `config`.
112    pub fn new(config: RelayConfig) -> Self {
113        Self {
114            config,
115            devices: DeviceRegistry::new(),
116        }
117    }
118
119    /// The device registry.
120    pub fn devices(&self) -> &DeviceRegistry {
121        &self.devices
122    }
123}
124
125/// Build the relay router.
126pub 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
135/// Run the relay server until shutdown.
136pub 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    // A device that vanished without closing its socket looks identical to an
142    // idle one, so entries are reaped on heartbeat staleness instead.
143    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
165/// Work out how this relay was addressed, from the connection's own headers.
166///
167/// A relay behind TLS termination sees plain HTTP on a loopback port, so the
168/// scheme and host it should advertise are only knowable from what the proxy
169/// forwards.
170fn 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
186/// Upgrade a device's outbound connection into the control channel.
187async 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
196/// Enroll a device, then serve its heartbeats until the connection ends.
197async fn control_session(socket: WebSocket, state: RelayState, observed: Option<String>) {
198    let (mut sink, mut stream) = socket.split();
199
200    // An unauthenticated peer must not be able to hold a connection open
201    // indefinitely, so enrollment is bounded in time.
202    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        // No detail about *why*: a device that guessed wrong learns nothing.
237        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    // Relay-assigned, never device-chosen: an attacker cannot pick or squat on
243    // another device's routing key.
244    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    // Fill the pool up front so the first request does not pay for a handshake.
267    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    // The control channel multiplexes nothing but coordination: device
276    // heartbeats one way, pool-refill requests the other.
277    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                        // A second enrollment on an attached connection is a
290                        // protocol error, not a re-key: ignore it rather than
291                        // reassigning an id.
292                        _ => 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
313/// Accept a data connection and park it in its device's pool.
314///
315/// The connection authenticates itself in its first frame rather than in the
316/// URL: query strings land in the access logs of the reverse proxies this relay
317/// is meant to sit behind, so a token there would be written to disk in
318/// plaintext on exactly the deployments that follow our own TLS advice.
319async fn data_handler(ws: WebSocketUpgrade, State(state): State<RelayState>) -> Response {
320    ws.on_upgrade(move |socket| attach_data_connection(socket, state))
321}
322
323/// Read the attach frame, verify it, and hand the socket to the device's pool.
324async 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    // A pool that is already full means the device over-supplied; closing the
352    // extra socket is better than holding it open forever.
353    if let Some(mut extra) = device.offer(socket).await {
354        let _ = extra.close().await;
355    }
356}
357
358/// Forward a public request to the addressed device and return its response.
359async 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        // The device is not attached: this is the relay reporting a missing
372        // upstream, which is exactly what 502 means.
373        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    // A WebSocket upgrade cannot be answered by buffering: the exchange has no
390    // end until one side closes. Because one request already owns one data
391    // connection for its lifetime, the same socket simply becomes the pipe —
392    // the connection-per-request model pays off here rather than needing a
393    // second mechanism.
394    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        // The device is attached but has no spare connection. 503 with a
416        // Retry-After is the honest answer: try again shortly.
417        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
449/// Whether these headers ask to switch protocols to WebSocket.
450fn 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
461/// Join a client's WebSocket to the device over one data connection.
462///
463/// The relay has already answered 101 by the time this runs — axum completes the
464/// handshake before invoking the callback — so a device that then refuses simply
465/// results in the client's socket closing.
466async 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    // The device answers with the status its own server returned; anything but
483    // a switch means the upgrade did not happen there.
484    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    // From here the two sockets are the same conversation: copy frames until
498    // either end hangs up.
499    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
528/// Largest request body the relay will buffer before forwarding.
529const MAX_BODY: usize = 8 * 1024 * 1024;
530
531/// Drive one request/response exchange over a dedicated data connection.
532///
533/// Wire shape: request header (text) → request body (binary) → response header
534/// (text) → response body (binary frames) → close.
535async 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
578/// Send a rejection and close, best-effort.
579async 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
591/// Serialize and send one protocol message.
592async 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
601/// Compare secrets without leaking their contents through timing.
602///
603/// The token is short and comparisons are rare, but an early-exit `==` on a
604/// shared secret is the kind of detail that is cheap to get right and awkward
605/// to retrofit.
606fn 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}