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, rate_limit_middleware, RateLimitConfig, RateLimiter};
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    /// Per-IP request limiting for this relay.
62    ///
63    /// The relay is the only place this can work for proxied traffic: a device
64    /// replays requests to its own loopback listener, so *its* limiter sees
65    /// 127.0.0.1 for every caller and cannot tell them apart. Here the real
66    /// client address is still visible.
67    pub rate_limit: RateLimitConfig,
68    /// Serve HTTPS directly instead of relying on a reverse proxy.
69    #[cfg(feature = "tls")]
70    pub tls: Option<crate::tls::TlsFiles>,
71    /// Public base URL of this relay, when the operator states it explicitly.
72    ///
73    /// Left unset, the relay derives it from each connection's `Host` (and
74    /// `X-Forwarded-*`) headers, so a relay behind TLS termination still tells
75    /// devices an address that actually works.
76    pub public_base: Option<String>,
77}
78
79impl RelayConfig {
80    /// Create a configuration with the given bind address and token.
81    pub fn new(bind: SocketAddr, enroll_token: impl Into<String>) -> Self {
82        Self {
83            bind,
84            enroll_token: enroll_token.into(),
85            rate_limit: RateLimitConfig::default(),
86            #[cfg(feature = "tls")]
87            tls: None,
88            public_base: None,
89        }
90    }
91
92    /// Terminate TLS in-process using these files.
93    #[cfg(feature = "tls")]
94    pub fn with_tls(mut self, files: crate::tls::TlsFiles) -> Self {
95        self.tls = Some(files);
96        self
97    }
98
99    /// Turn per-IP request limiting off.
100    pub fn without_rate_limit(mut self) -> Self {
101        self.rate_limit.enabled = false;
102        self
103    }
104
105    /// Set the public base URL advertised to devices.
106    pub fn with_public_base(mut self, base: impl Into<String>) -> Self {
107        self.public_base = Some(base.into().trim_end_matches('/').to_string());
108        self
109    }
110
111    /// The base URL to advertise, preferring what the operator configured.
112    ///
113    /// `observed` is what the connection itself says this relay is reachable at.
114    /// Falling back to the bind address is a last resort — it is right only when
115    /// nothing is in front of the relay.
116    pub fn public_base_or(&self, observed: Option<String>) -> String {
117        self.public_base
118            .clone()
119            .or(observed)
120            .unwrap_or_else(|| format!("http://{}", self.bind))
121    }
122
123    /// Public URL that routes to `device_id`.
124    pub fn public_url_for(&self, device_id: &str, observed: Option<String>) -> String {
125        format!("{}/d/{}", self.public_base_or(observed), device_id)
126    }
127}
128
129/// The corrected `--public-base` to suggest when the stated base implies a
130/// port nobody is listening on.
131///
132/// A base URL with no explicit port implies the scheme default, so when the
133/// relay listens elsewhere every printed URL points at a port that only works
134/// if a proxy or NAT forwards the default port to it. That setup is
135/// legitimate and undetectable, so the correction is a suggestion for the
136/// startup banner — the stated base is never rewritten silently. An explicit
137/// port, even a mismatched one, is the operator stating intent.
138pub fn public_base_port_hint(base: &str, listen_port: u16) -> Option<String> {
139    let (scheme, rest) = base.split_once("://")?;
140    let default_port: u16 = match scheme {
141        "https" => 443,
142        "http" => 80,
143        _ => return None,
144    };
145    if listen_port == default_port {
146        return None;
147    }
148    let authority_end = rest.find('/').unwrap_or(rest.len());
149    let authority = &rest[..authority_end];
150    // The port separator is the colon after the host — for an IPv6 literal
151    // that means after the closing bracket, not one inside it.
152    let has_port = match authority.rfind(']') {
153        Some(bracket) => authority[bracket..].contains(':'),
154        None => authority.contains(':'),
155    };
156    if has_port {
157        return None;
158    }
159    Some(format!(
160        "{scheme}://{authority}:{listen_port}{}",
161        &rest[authority_end..]
162    ))
163}
164
165/// Shared relay state.
166#[derive(Debug, Clone)]
167pub struct RelayState {
168    config: RelayConfig,
169    devices: DeviceRegistry,
170}
171
172impl RelayState {
173    /// Create state for `config`.
174    pub fn new(config: RelayConfig) -> Self {
175        Self {
176            config,
177            devices: DeviceRegistry::new(),
178        }
179    }
180
181    /// The device registry.
182    pub fn devices(&self) -> &DeviceRegistry {
183        &self.devices
184    }
185}
186
187/// Build the relay router.
188///
189/// Every route but `/health` is rate limited per client IP. Enrolment is the
190/// reason: without a limit, a weak enrolment token can be guessed at line speed,
191/// and the relay is the only place that sees who is asking.
192pub fn relay_router(state: RelayState) -> Router {
193    let limiter = Arc::new(RateLimiter::new(state.config.rate_limit.clone()));
194
195    Router::new()
196        .route("/health", get(|| async { "OK" }))
197        .route("/relay/v1/control", get(control_handler))
198        .route("/relay/v1/data", get(data_handler))
199        .route("/relay/v1/devices", get(devices_handler))
200        .route("/d/{*rest}", any(proxy_handler))
201        .layer(axum::middleware::from_fn_with_state(
202            limiter,
203            rate_limit_middleware,
204        ))
205        .with_state(state)
206}
207
208/// Run the relay server until shutdown.
209pub async fn serve_relay(config: RelayConfig) -> crate::Result<()> {
210    let bind = config.bind;
211    #[cfg(feature = "tls")]
212    let tls = config.tls.clone();
213    let state = RelayState::new(config);
214    let router = relay_router(state.clone());
215
216    // A device that vanished without closing its socket looks identical to an
217    // idle one, so entries are reaped on heartbeat staleness instead.
218    let sweeper = state.devices().clone();
219    tokio::spawn(async move {
220        let mut ticker = tokio::time::interval(HEARTBEAT_TIMEOUT / 3);
221        loop {
222            ticker.tick().await;
223            for id in sweeper.evict_stale(HEARTBEAT_TIMEOUT) {
224                tracing::info!(target: "relay", device_id = %id, "device evicted (no heartbeat)");
225            }
226        }
227    });
228
229    tracing::info!("relay listening on {}", bind);
230
231    let listener = tokio::net::TcpListener::bind(bind)
232        .await
233        .map_err(ShellTunnelError::Io)?;
234    // Connection info is what the rate limiter keys on; without it every caller
235    // would look identical.
236    let service = router.into_make_service_with_connect_info::<SocketAddr>();
237
238    #[cfg(feature = "tls")]
239    if let Some(files) = tls {
240        // Loaded before serving so a bad certificate stops startup rather than
241        // failing every connection at handshake time.
242        let config = crate::tls::acceptor(files.load()?);
243        // Renewal should not require a restart.
244        crate::tls::watch(files, config.clone());
245        let std_listener = listener.into_std().map_err(ShellTunnelError::Io)?;
246        return axum_server::from_tcp_rustls(std_listener, config)
247            .map_err(ShellTunnelError::Io)?
248            .serve(service)
249            .await
250            .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())));
251    }
252
253    axum::serve(listener, service)
254        .await
255        .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())))?;
256    Ok(())
257}
258
259/// Whether `name` is usable as a routing key in `/d/<name>/…`.
260///
261/// Deliberately narrow: the name lands in a URL path, so anything that could
262/// need escaping, traverse a path, or collide with the relay's own routes is
263/// rejected rather than sanitized.
264fn is_valid_device_name(name: &str) -> bool {
265    !name.is_empty()
266        && name.len() <= 64
267        && name
268            .chars()
269            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
270}
271
272/// Work out how this relay was addressed, from the connection's own headers.
273///
274/// A relay behind TLS termination sees plain HTTP on a loopback port, so the
275/// scheme and host it should advertise are only knowable from what the proxy
276/// forwards.
277fn observed_base(headers: &HeaderMap, tls: bool) -> Option<String> {
278    let host = headers
279        .get("x-forwarded-host")
280        .or_else(|| headers.get(axum::http::header::HOST))
281        .and_then(|value| value.to_str().ok())?;
282    if host.is_empty() {
283        return None;
284    }
285    // A proxy's own statement wins; failing that, the relay knows whether it
286    // terminated TLS itself. Guessing `http` while serving HTTPS would advertise
287    // a URL that the relay itself refuses.
288    let scheme = headers
289        .get("x-forwarded-proto")
290        .and_then(|value| value.to_str().ok())
291        .map(|proto| proto.split(',').next().unwrap_or(proto).trim().to_string())
292        .unwrap_or_else(|| if tls { "https" } else { "http" }.to_string());
293    Some(format!("{scheme}://{host}"))
294}
295
296/// Whether this relay terminates TLS itself.
297fn serves_tls(_state: &RelayState) -> bool {
298    #[cfg(feature = "tls")]
299    {
300        _state.config.tls.is_some()
301    }
302    #[cfg(not(feature = "tls"))]
303    {
304        false
305    }
306}
307
308/// Upgrade a device's outbound connection into the control channel.
309async fn control_handler(
310    ws: WebSocketUpgrade,
311    State(state): State<RelayState>,
312    headers: HeaderMap,
313) -> impl IntoResponse {
314    let observed = observed_base(&headers, serves_tls(&state));
315    ws.on_upgrade(move |socket| control_session(socket, state, observed))
316}
317
318/// Enroll a device, then serve its heartbeats until the connection ends.
319async fn control_session(socket: WebSocket, state: RelayState, observed: Option<String>) {
320    let (mut sink, mut stream) = socket.split();
321
322    // An unauthenticated peer must not be able to hold a connection open
323    // indefinitely, so enrollment is bounded in time.
324    let first = match tokio::time::timeout(ENROLL_TIMEOUT, stream.next()).await {
325        Ok(Some(Ok(Message::Text(text)))) => text,
326        _ => return,
327    };
328
329    let enroll = match serde_json::from_str::<DeviceMessage>(&first) {
330        Ok(DeviceMessage::Enroll {
331            enroll_token,
332            version,
333            label,
334            device_name,
335        }) => (enroll_token, version, label, device_name),
336        _ => {
337            reject_and_close(
338                &mut sink,
339                reject::BAD_HANDSHAKE,
340                "expected an enroll message",
341            )
342            .await;
343            return;
344        }
345    };
346    let (enroll_token, version, label, device_name) = enroll;
347
348    if version != PROTOCOL_VERSION {
349        reject_and_close(
350            &mut sink,
351            reject::UNSUPPORTED_VERSION,
352            &format!("relay speaks protocol version {PROTOCOL_VERSION}"),
353        )
354        .await;
355        return;
356    }
357
358    if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
359        // No detail about *why*: a device that guessed wrong learns nothing.
360        tracing::debug!(target: "relay", "enrollment rejected: bad token");
361        reject_and_close(&mut sink, reject::BAD_TOKEN, "enrollment refused").await;
362        return;
363    }
364
365    // A named device keeps one URL across reconnects, which is what makes the
366    // relay usable when whoever calls the device cannot read its console. An
367    // unnamed one gets a random id, which nobody can guess but which changes
368    // every time it attaches.
369    let device_id = match device_name {
370        Some(name) if !is_valid_device_name(&name) => {
371            reject_and_close(
372                &mut sink,
373                reject::BAD_DEVICE_NAME,
374                "device names may use letters, digits, '-' and '_' (1-64 characters)",
375            )
376            .await;
377            return;
378        }
379        // Re-attaching under an existing name replaces the old entry rather
380        // than being refused: after a network drop the relay still holds a
381        // connection it cannot know is dead, and refusing would lock the device
382        // out until the heartbeat timeout expired. Only holders of the enrol
383        // token can do this, which is the same trust level as attaching at all.
384        Some(name) => name,
385        None => generate_api_key(),
386    };
387    let public_url = state.config.public_url_for(&device_id, observed);
388    let registry::DeviceHandles {
389        device,
390        mut refill_rx,
391    } = state.devices.attach(&device_id, label.clone());
392    tracing::info!(
393        target: "relay",
394        device_id = %device_id,
395        label = label.as_deref().unwrap_or("-"),
396        "device attached"
397    );
398
399    let enrolled = RelayMessage::Enrolled {
400        device_id: device_id.clone(),
401        public_url,
402    };
403    if send_json(&mut sink, &enrolled).await.is_err() {
404        state.devices.detach(&device_id);
405        return;
406    }
407
408    // Fill the pool up front so the first request does not pay for a handshake.
409    let fill = RelayMessage::OpenData {
410        count: registry::POOL_TARGET,
411    };
412    if send_json(&mut sink, &fill).await.is_err() {
413        state.devices.detach(&device_id);
414        return;
415    }
416
417    // The control channel multiplexes nothing but coordination: device
418    // heartbeats one way, pool-refill requests the other.
419    loop {
420        tokio::select! {
421            incoming = stream.next() => {
422                let Some(Ok(message)) = incoming else { break };
423                match message {
424                    Message::Text(text) => match serde_json::from_str::<DeviceMessage>(&text) {
425                        Ok(DeviceMessage::Heartbeat) => {
426                            device.touch();
427                            if send_json(&mut sink, &RelayMessage::HeartbeatAck).await.is_err() {
428                                break;
429                            }
430                        }
431                        // A second enrollment on an attached connection is a
432                        // protocol error, not a re-key: ignore it rather than
433                        // reassigning an id.
434                        _ => continue,
435                    },
436                    Message::Close(_) => break,
437                    _ => continue,
438                }
439            }
440            refill = refill_rx.recv() => {
441                if refill.is_none() {
442                    break;
443                }
444                if send_json(&mut sink, &RelayMessage::OpenData { count: 1 }).await.is_err() {
445                    break;
446                }
447            }
448        }
449    }
450
451    state.devices.detach(&device_id);
452    tracing::info!(target: "relay", device_id = %device_id, "device detached");
453}
454
455/// List the devices currently attached.
456///
457/// Authenticated with the enrolment token, because the answer is only useful to
458/// whoever operates this relay — and anyone holding that token could attach a
459/// device anyway, so listing them reveals nothing new.
460async fn devices_handler(State(state): State<RelayState>, headers: HeaderMap) -> Response {
461    let presented = headers
462        .get(axum::http::header::AUTHORIZATION)
463        .and_then(|value| value.to_str().ok())
464        .and_then(|value| value.strip_prefix("Bearer "))
465        .unwrap_or("");
466    if !constant_time_eq(presented, &state.config.enroll_token) {
467        return StatusCode::UNAUTHORIZED.into_response();
468    }
469
470    let base = state
471        .config
472        .public_base_or(observed_base(&headers, serves_tls(&state)));
473    let devices: Vec<_> = state
474        .devices
475        .list()
476        .into_iter()
477        .map(|device| {
478            let url = format!("{}/d/{}", base, device.id);
479            serde_json::json!({
480                "id": device.id,
481                "label": device.label,
482                "attached_secs": device.attached_secs,
483                "last_seen_secs": device.last_seen_secs,
484                "public_url": url,
485            })
486        })
487        .collect();
488
489    axum::Json(serde_json::json!({ "devices": devices })).into_response()
490}
491
492/// Accept a data connection and park it in its device's pool.
493///
494/// The connection authenticates itself in its first frame rather than in the
495/// URL: query strings land in the access logs of the reverse proxies this relay
496/// is meant to sit behind, so a token there would be written to disk in
497/// plaintext on exactly the deployments that follow our own TLS advice.
498async fn data_handler(ws: WebSocketUpgrade, State(state): State<RelayState>) -> Response {
499    ws.on_upgrade(move |socket| attach_data_connection(socket, state))
500}
501
502/// Read the attach frame, verify it, and hand the socket to the device's pool.
503async fn attach_data_connection(mut socket: WebSocket, state: RelayState) {
504    let first = tokio::time::timeout(ENROLL_TIMEOUT, socket.recv()).await;
505    let Ok(Some(Ok(Message::Text(text)))) = first else {
506        let _ = socket.close().await;
507        return;
508    };
509
510    let Ok(DeviceMessage::Attach {
511        device_id,
512        enroll_token,
513    }) = serde_json::from_str::<DeviceMessage>(&text)
514    else {
515        let _ = socket.close().await;
516        return;
517    };
518
519    if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
520        tracing::debug!(target: "relay", "data connection rejected: bad token");
521        let _ = socket.close().await;
522        return;
523    }
524
525    let Some(device) = state.devices.get(&device_id) else {
526        let _ = socket.close().await;
527        return;
528    };
529
530    // A pool that is already full means the device over-supplied; closing the
531    // extra socket is better than holding it open forever.
532    if let Some(mut extra) = device.offer(socket).await {
533        let _ = extra.close().await;
534    }
535}
536
537/// Forward a public request to the addressed device and return its response.
538async fn proxy_handler(State(state): State<RelayState>, request: Request) -> Response {
539    let path_and_query = request
540        .uri()
541        .path_and_query()
542        .map(|p| p.as_str().to_string())
543        .unwrap_or_else(|| request.uri().path().to_string());
544
545    let Some((device_id, tail)) = split_device_path(&path_and_query) else {
546        return StatusCode::NOT_FOUND.into_response();
547    };
548
549    let Some(device) = state.devices.get(device_id) else {
550        // The device is not attached: this is the relay reporting a missing
551        // upstream, which is exactly what 502 means.
552        return (StatusCode::BAD_GATEWAY, "device is not connected").into_response();
553    };
554
555    let method = request.method().to_string();
556    let headers: Vec<(String, String)> = request
557        .headers()
558        .iter()
559        .filter(|(name, _)| is_forwardable(name.as_str()))
560        .filter_map(|(name, value)| {
561            value
562                .to_str()
563                .ok()
564                .map(|v| (name.as_str().to_string(), v.to_string()))
565        })
566        .collect();
567
568    // A WebSocket upgrade cannot be answered by buffering: the exchange has no
569    // end until one side closes. Because one request already owns one data
570    // connection for its lifetime, the same socket simply becomes the pipe —
571    // the connection-per-request model pays off here rather than needing a
572    // second mechanism.
573    if is_websocket_upgrade(request.headers()) {
574        let (mut parts, _) = request.into_parts();
575        let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
576            Ok(upgrade) => upgrade,
577            Err(rejection) => return rejection.into_response(),
578        };
579        let proxied = ProxyRequest {
580            method,
581            path: tail,
582            headers,
583            websocket: true,
584        };
585        return upgrade.on_upgrade(move |client| pipe_websocket(client, device, proxied));
586    }
587
588    let body = match axum::body::to_bytes(request.into_body(), MAX_BODY).await {
589        Ok(body) => body,
590        Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
591    };
592
593    let Some(conn) = device.take(POOL_WAIT).await else {
594        // The device is attached but has no spare connection. 503 with a
595        // Retry-After is the honest answer: try again shortly.
596        return (
597            StatusCode::SERVICE_UNAVAILABLE,
598            [("retry-after", "1")],
599            "no data connection available",
600        )
601            .into_response();
602    };
603
604    match tokio::time::timeout(
605        REQUEST_TIMEOUT,
606        forward(
607            conn,
608            ProxyRequest {
609                method,
610                path: tail,
611                headers,
612                websocket: false,
613            },
614            body,
615        ),
616    )
617    .await
618    {
619        Ok(Ok(response)) => response,
620        Ok(Err(reason)) => {
621            tracing::debug!(target: "relay", device_id = %device.id, reason, "proxy failed");
622            (StatusCode::BAD_GATEWAY, "device did not answer").into_response()
623        }
624        Err(_) => (StatusCode::GATEWAY_TIMEOUT, "device timed out").into_response(),
625    }
626}
627
628/// Whether these headers ask to switch protocols to WebSocket.
629fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
630    let header_contains = |name: axum::http::HeaderName, needle: &str| {
631        headers
632            .get(name)
633            .and_then(|value| value.to_str().ok())
634            .is_some_and(|value| value.to_ascii_lowercase().contains(needle))
635    };
636    header_contains(axum::http::header::UPGRADE, "websocket")
637        && header_contains(axum::http::header::CONNECTION, "upgrade")
638}
639
640/// Join a client's WebSocket to the device over one data connection.
641///
642/// The relay has already answered 101 by the time this runs — axum completes the
643/// handshake before invoking the callback — so a device that then refuses simply
644/// results in the client's socket closing.
645async fn pipe_websocket(mut client: WebSocket, device: Arc<Device>, request: ProxyRequest) {
646    let Some(mut conn) = device.take(POOL_WAIT).await else {
647        tracing::debug!(target: "relay", device_id = %device.id, "no data connection for websocket");
648        let _ = client.close().await;
649        return;
650    };
651
652    let Ok(header) = serde_json::to_string(&request) else {
653        let _ = client.close().await;
654        return;
655    };
656    if conn.send(Message::Text(header.into())).await.is_err() {
657        let _ = client.close().await;
658        return;
659    }
660
661    // The device answers with the status its own server returned; anything but
662    // a switch means the upgrade did not happen there.
663    let switched = matches!(
664        conn.recv().await,
665        Some(Ok(Message::Text(ref text)))
666            if serde_json::from_str::<ProxyResponse>(text)
667                .map(|response| response.status == 101)
668                .unwrap_or(false)
669    );
670    if !switched {
671        let _ = client.close().await;
672        let _ = conn.close().await;
673        return;
674    }
675
676    // From here the two sockets are the same conversation: copy frames until
677    // either end hangs up.
678    loop {
679        tokio::select! {
680            from_client = client.recv() => {
681                match from_client {
682                    Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
683                    Some(Ok(message)) => {
684                        if conn.send(message).await.is_err() {
685                            break;
686                        }
687                    }
688                }
689            }
690            from_device = conn.recv() => {
691                match from_device {
692                    Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
693                    Some(Ok(message)) => {
694                        if client.send(message).await.is_err() {
695                            break;
696                        }
697                    }
698                }
699            }
700        }
701    }
702
703    let _ = client.close().await;
704    let _ = conn.close().await;
705}
706
707/// Largest request body the relay will buffer before forwarding.
708const MAX_BODY: usize = 8 * 1024 * 1024;
709
710/// Drive one request/response exchange over a dedicated data connection.
711///
712/// Wire shape: request header (text) → request body (binary) → response header
713/// (text) → response body (binary frames) → close.
714async fn forward(
715    mut conn: WebSocket,
716    request: ProxyRequest,
717    body: Bytes,
718) -> Result<Response, &'static str> {
719    let header = serde_json::to_string(&request).map_err(|_| "request-encode")?;
720    conn.send(Message::Text(header.into()))
721        .await
722        .map_err(|_| "request-header-send")?;
723    conn.send(Message::Binary(body))
724        .await
725        .map_err(|_| "request-body-send")?;
726
727    let head: ProxyResponse = loop {
728        match conn.recv().await {
729            Some(Ok(Message::Text(text))) => {
730                break serde_json::from_str(&text).map_err(|_| "response-decode")?
731            }
732            Some(Ok(_)) => continue,
733            _ => return Err("response-header-missing"),
734        }
735    };
736
737    let mut body = Vec::new();
738    while let Some(Ok(message)) = conn.recv().await {
739        match message {
740            Message::Binary(chunk) => body.extend_from_slice(&chunk),
741            Message::Close(_) => break,
742            _ => continue,
743        }
744    }
745
746    let mut response = Response::builder().status(head.status);
747    for (name, value) in head.headers {
748        if is_forwardable(&name) {
749            response = response.header(name, value);
750        }
751    }
752    response
753        .body(axum::body::Body::from(body))
754        .map_err(|_| "response-build")
755}
756
757/// Send a rejection and close, best-effort.
758async fn reject_and_close<S>(sink: &mut S, code: &str, message: &str)
759where
760    S: SinkExt<Message> + Unpin,
761{
762    let rejected = RelayMessage::Rejected {
763        code: code.to_string(),
764        message: message.to_string(),
765    };
766    let _ = send_json(sink, &rejected).await;
767    let _ = sink.close().await;
768}
769
770/// Serialize and send one protocol message.
771async fn send_json<S, T>(sink: &mut S, message: &T) -> Result<(), ()>
772where
773    S: SinkExt<Message> + Unpin,
774    T: serde::Serialize,
775{
776    let json = serde_json::to_string(message).map_err(|_| ())?;
777    sink.send(Message::Text(json.into())).await.map_err(|_| ())
778}
779
780/// Compare secrets without leaking their contents through timing.
781///
782/// The token is short and comparisons are rare, but an early-exit `==` on a
783/// shared secret is the kind of detail that is cheap to get right and awkward
784/// to retrofit.
785fn constant_time_eq(a: &str, b: &str) -> bool {
786    let (a, b) = (a.as_bytes(), b.as_bytes());
787    if a.len() != b.len() {
788        return false;
789    }
790    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
791}
792
793#[cfg(test)]
794mod tests {
795    use super::*;
796
797    fn config() -> RelayConfig {
798        RelayConfig::new("127.0.0.1:0".parse().unwrap(), "secret")
799    }
800
801    #[test]
802    fn a_portless_base_on_a_nondefault_port_gets_a_corrected_suggestion() {
803        // The failure this pins down: `--public-base https://labs.example.com`
804        // with the relay listening on 8443 printed join and device URLs that
805        // imply port 443, which nobody was serving. The hint is the corrected
806        // value to suggest — never applied silently, because a proxy or NAT
807        // forwarding 443 -> 8443 makes the portless form legitimate.
808        assert_eq!(
809            public_base_port_hint("https://labs.example.com", 8443).as_deref(),
810            Some("https://labs.example.com:8443")
811        );
812        assert_eq!(
813            public_base_port_hint("http://relay.local", 8080).as_deref(),
814            Some("http://relay.local:8080")
815        );
816    }
817
818    #[test]
819    fn a_base_matching_the_scheme_default_needs_no_hint() {
820        assert_eq!(public_base_port_hint("https://labs.example.com", 443), None);
821        assert_eq!(public_base_port_hint("http://relay.local", 80), None);
822    }
823
824    #[test]
825    fn an_explicit_port_is_the_operator_stating_intent() {
826        // Explicit ports are never second-guessed: a proxy may remap them.
827        assert_eq!(
828            public_base_port_hint("https://labs.example.com:8443", 8443),
829            None
830        );
831        assert_eq!(
832            public_base_port_hint("https://labs.example.com:9000", 8443),
833            None
834        );
835        assert_eq!(
836            public_base_port_hint("https://labs.example.com:443", 8443),
837            None
838        );
839    }
840
841    #[test]
842    fn the_port_is_spliced_into_the_authority_not_the_tail() {
843        // A base may carry a path prefix; the port belongs after the host.
844        assert_eq!(
845            public_base_port_hint("https://labs.example.com/relay", 8443).as_deref(),
846            Some("https://labs.example.com:8443/relay")
847        );
848    }
849
850    #[test]
851    fn ipv6_literals_look_for_the_port_after_the_bracket() {
852        assert_eq!(
853            public_base_port_hint("https://[::1]", 8443).as_deref(),
854            Some("https://[::1]:8443")
855        );
856        assert_eq!(public_base_port_hint("https://[::1]:8443", 8443), None);
857    }
858
859    #[test]
860    fn an_unrecognized_scheme_is_left_alone() {
861        assert_eq!(public_base_port_hint("ws://relay.local", 8443), None);
862    }
863
864    #[test]
865    fn public_url_uses_the_device_path_prefix() {
866        let config = config().with_public_base("https://relay.example.com/");
867        assert_eq!(
868            config.public_url_for("dev-1", None),
869            "https://relay.example.com/d/dev-1"
870        );
871    }
872
873    #[test]
874    fn public_base_defaults_to_the_bind_address() {
875        let config = RelayConfig::new("127.0.0.1:8443".parse().unwrap(), "secret");
876        assert_eq!(
877            config.public_url_for("d", None),
878            "http://127.0.0.1:8443/d/d"
879        );
880    }
881
882    #[test]
883    fn an_observed_address_is_used_when_the_operator_configured_none() {
884        let config = config();
885        assert_eq!(
886            config.public_url_for("dev-1", Some("https://relay.example.com".into())),
887            "https://relay.example.com/d/dev-1"
888        );
889    }
890
891    #[test]
892    fn a_configured_base_wins_over_what_the_connection_observed() {
893        let config = config().with_public_base("https://canonical.example");
894        assert_eq!(
895            config.public_url_for("dev-1", Some("https://whatever.invalid".into())),
896            "https://canonical.example/d/dev-1"
897        );
898    }
899
900    #[test]
901    fn the_forwarded_scheme_and_host_are_preferred_over_the_direct_host() {
902        let mut headers = HeaderMap::new();
903        headers.insert(axum::http::header::HOST, "127.0.0.1:8443".parse().unwrap());
904        assert_eq!(
905            observed_base(&headers, false).as_deref(),
906            Some("http://127.0.0.1:8443")
907        );
908
909        headers.insert("x-forwarded-proto", "https".parse().unwrap());
910        headers.insert("x-forwarded-host", "relay.example.com".parse().unwrap());
911        assert_eq!(
912            observed_base(&headers, false).as_deref(),
913            Some("https://relay.example.com")
914        );
915    }
916
917    #[test]
918    fn a_proxy_chain_scheme_takes_the_first_entry() {
919        let mut headers = HeaderMap::new();
920        headers.insert(
921            axum::http::header::HOST,
922            "relay.example.com".parse().unwrap(),
923        );
924        headers.insert("x-forwarded-proto", "https, http".parse().unwrap());
925        assert_eq!(
926            observed_base(&headers, false).as_deref(),
927            Some("https://relay.example.com")
928        );
929    }
930
931    #[test]
932    fn no_host_header_means_nothing_observed() {
933        assert!(observed_base(&HeaderMap::new(), false).is_none());
934    }
935
936    #[test]
937    fn terminating_tls_makes_the_advertised_url_https() {
938        // Advertising http:// while refusing plaintext would hand out a URL the
939        // relay itself rejects.
940        let mut headers = HeaderMap::new();
941        headers.insert(
942            axum::http::header::HOST,
943            "relay.example.com".parse().unwrap(),
944        );
945        assert_eq!(
946            observed_base(&headers, true).as_deref(),
947            Some("https://relay.example.com")
948        );
949    }
950
951    #[test]
952    fn device_names_must_be_url_path_safe() {
953        assert!(is_valid_device_name("build-box"));
954        assert!(is_valid_device_name("laptop_2"));
955        assert!(is_valid_device_name("a"));
956
957        assert!(!is_valid_device_name(""));
958        assert!(!is_valid_device_name("has space"));
959        assert!(!is_valid_device_name("../escape"));
960        assert!(!is_valid_device_name("slash/inside"));
961        assert!(!is_valid_device_name("querylike?x=1"));
962        assert!(!is_valid_device_name(&"x".repeat(65)));
963    }
964
965    #[test]
966    fn constant_time_eq_matches_equality() {
967        assert!(constant_time_eq("abc", "abc"));
968        assert!(!constant_time_eq("abc", "abd"));
969        assert!(!constant_time_eq("abc", "ab"));
970        assert!(constant_time_eq("", ""));
971    }
972}