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        ConnectInfo, FromRequestParts, Request, State,
30    },
31    http::{HeaderMap, StatusCode},
32    response::{IntoResponse, Response},
33    routing::{any, get},
34    Extension, Router,
35};
36use futures_util::{SinkExt, StreamExt};
37
38use crate::error::ShellTunnelError;
39use crate::security::{
40    generate_api_key, rate_limit_middleware, RateLimitCharge, RateLimitConfig, RateLimiter,
41};
42use protocol::{reject, DeviceMessage, RelayMessage, PROTOCOL_VERSION};
43use proxy::{
44    is_forwardable, split_device_path, ProxyRequest, ProxyResponse, POOL_WAIT, REQUEST_TIMEOUT,
45};
46use registry::{Device, DeviceRegistry};
47
48pub use registry::{DeviceRegistry as Registry, POOL_TARGET};
49
50/// How long a device may go without a heartbeat before it is considered gone.
51pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(90);
52
53/// How long to wait for the enrollment frame before dropping a connection.
54const ENROLL_TIMEOUT: Duration = Duration::from_secs(10);
55
56/// Largest response body that can cross the relay, because a device sends one
57/// as a single WebSocket frame.
58///
59/// This value is published — `docs/USAGE.md` §8 and §10 name it, and it is the
60/// reason `fs/file` over a relay tells callers to use `Range`. It was not
61/// declared anywhere until 0.21.1: it was `tungstenite`'s **default**
62/// `max_frame_size`, which this crate never set, so a documented contract rested
63/// on a dependency's default and a version bump could have moved it in silence.
64/// The number is unchanged; what changed is who owns it.
65///
66/// Set on both ends, since the reader is what refuses: the relay reads device
67/// responses, and a device reads relayed request bodies. [`MAX_BODY`] bounds the
68/// request direction separately and more tightly.
69pub const MAX_RELAY_FRAME: usize = 16 * 1024 * 1024;
70
71/// Relay server settings.
72#[derive(Debug, Clone)]
73pub struct RelayConfig {
74    /// Address to listen on.
75    pub bind: SocketAddr,
76    /// Secret a device must present to attach.
77    pub enroll_token: String,
78    /// Per-IP request limiting for this relay.
79    ///
80    /// The relay is the only place this can work for proxied traffic: a device
81    /// replays requests to its own loopback listener, so *its* limiter sees
82    /// 127.0.0.1 for every caller and cannot tell them apart. Here the real
83    /// client address is still visible.
84    pub rate_limit: RateLimitConfig,
85    /// Serve HTTPS directly instead of relying on a reverse proxy.
86    #[cfg(feature = "tls")]
87    pub tls: Option<crate::tls::TlsFiles>,
88    /// Public base URL of this relay, when the operator states it explicitly.
89    ///
90    /// Left unset, the relay derives it from each connection's `Host` (and
91    /// `X-Forwarded-*`) headers, so a relay behind TLS termination still tells
92    /// devices an address that actually works.
93    pub public_base: Option<String>,
94}
95
96impl RelayConfig {
97    /// Create a configuration with the given bind address and token.
98    pub fn new(bind: SocketAddr, enroll_token: impl Into<String>) -> Self {
99        Self {
100            bind,
101            enroll_token: enroll_token.into(),
102            rate_limit: RateLimitConfig::default(),
103            #[cfg(feature = "tls")]
104            tls: None,
105            public_base: None,
106        }
107    }
108
109    /// Terminate TLS in-process using these files.
110    #[cfg(feature = "tls")]
111    pub fn with_tls(mut self, files: crate::tls::TlsFiles) -> Self {
112        self.tls = Some(files);
113        self
114    }
115
116    /// Turn per-IP request limiting off.
117    pub fn without_rate_limit(mut self) -> Self {
118        self.rate_limit.enabled = false;
119        self
120    }
121
122    /// Set the public base URL advertised to devices.
123    pub fn with_public_base(mut self, base: impl Into<String>) -> Self {
124        self.public_base = Some(base.into().trim_end_matches('/').to_string());
125        self
126    }
127
128    /// The operator-configured base with this relay's listen port filled in when
129    /// the base named no port.
130    ///
131    /// A base written without a port (`https://relay.example.com`) means the
132    /// scheme default to a browser, but an operator who bound 8443 and named no
133    /// proxy meant *this* relay — so the listen port is the least-surprising
134    /// fill, and every advertised URL then reaches something. An explicit port is
135    /// intent and is left untouched, which is how a reverse proxy on 443 keeps a
136    /// port-less base. `observed` bases are never touched here: they already name
137    /// a reachable authority.
138    pub fn resolved_public_base(&self) -> Option<String> {
139        self.public_base.as_deref().map(|base| {
140            public_base_port_hint(base, self.bind.port()).unwrap_or_else(|| base.to_string())
141        })
142    }
143
144    /// The base URL to advertise, preferring what the operator configured.
145    ///
146    /// `observed` is what the connection itself says this relay is reachable at.
147    /// Falling back to the bind address is a last resort — it is right only when
148    /// nothing is in front of the relay.
149    pub fn public_base_or(&self, observed: Option<String>) -> String {
150        self.resolved_public_base()
151            .or(observed)
152            .unwrap_or_else(|| format!("http://{}", self.bind))
153    }
154
155    /// Public URL that routes to `device_id`.
156    pub fn public_url_for(&self, device_id: &str, observed: Option<String>) -> String {
157        format!("{}/d/{}", self.public_base_or(observed), device_id)
158    }
159}
160
161/// The corrected `--public-base` to suggest when the stated base implies a
162/// port nobody is listening on.
163///
164/// A base URL with no explicit port implies the scheme default, so when the
165/// relay listens elsewhere every printed URL points at a port that only works
166/// if a proxy or NAT forwards the default port to it. That setup is
167/// legitimate and undetectable, so the correction is a suggestion for the
168/// startup banner — the stated base is never rewritten silently. An explicit
169/// port, even a mismatched one, is the operator stating intent.
170pub fn public_base_port_hint(base: &str, listen_port: u16) -> Option<String> {
171    let (scheme, rest) = base.split_once("://")?;
172    let default_port: u16 = match scheme {
173        "https" => 443,
174        "http" => 80,
175        _ => return None,
176    };
177    if listen_port == default_port {
178        return None;
179    }
180    let authority_end = rest.find('/').unwrap_or(rest.len());
181    let authority = &rest[..authority_end];
182    // The port separator is the colon after the host — for an IPv6 literal
183    // that means after the closing bracket, not one inside it.
184    let has_port = match authority.rfind(']') {
185        Some(bracket) => authority[bracket..].contains(':'),
186        None => authority.contains(':'),
187    };
188    if has_port {
189        return None;
190    }
191    Some(format!(
192        "{scheme}://{authority}:{listen_port}{}",
193        &rest[authority_end..]
194    ))
195}
196
197/// Shared relay state.
198#[derive(Debug, Clone)]
199pub struct RelayState {
200    config: RelayConfig,
201    devices: DeviceRegistry,
202    /// The same limiter the middleware runs, reachable from the handlers.
203    ///
204    /// Not duplication: a device's routes are charged by the middleware and
205    /// refunded by the handler once the enrolment token has been proven, and
206    /// both have to be talking about one set of counters for that to mean
207    /// anything.
208    limiter: Arc<RateLimiter>,
209}
210
211impl RelayState {
212    /// Create state for `config`.
213    pub fn new(config: RelayConfig) -> Self {
214        let limiter = Arc::new(RateLimiter::new(config.rate_limit.clone()));
215        Self {
216            config,
217            devices: DeviceRegistry::new(),
218            limiter,
219        }
220    }
221
222    /// The device registry.
223    pub fn devices(&self) -> &DeviceRegistry {
224        &self.devices
225    }
226}
227
228/// Build the relay router.
229///
230/// Every route but `/health` is rate limited per client IP. Enrolment is the
231/// reason: without a limit, a weak enrolment token can be guessed at line speed,
232/// and the relay is the only place that sees who is asking.
233///
234/// A device's own routes are charged like anything else and then **refunded
235/// once the enrolment token has been proven**, so the bucket accumulates only
236/// attempts that failed or were abandoned. Without that refund the two kinds of
237/// traffic share a budget, and the amount of device traffic is set by whoever
238/// calls the device: the relay's one-data-connection-per-request model has the
239/// device open a replacement socket for every proxied request, so public load
240/// on an address could spend the budget a device on that address needs to stay
241/// attached — and it did, refusing four enrolments in the field while the
242/// device backed off in silence.
243///
244/// What the refund gives up, stated rather than left implicit: a holder of the
245/// enrol token can now open connections without a per-address ceiling. That is
246/// a trade this relay can afford because the token already grants attaching
247/// connections for *any* device on it — the relay is single-tenant by design —
248/// so a limit was never what stood between a token holder and the relay. The
249/// pool bounds what those connections cost: a full pool closes the extra
250/// socket rather than keeping it.
251pub fn relay_router(state: RelayState) -> Router {
252    let limiter = Arc::clone(&state.limiter);
253
254    Router::new()
255        .route("/health", get(|| async { "OK" }))
256        .route("/relay/v1/control", get(control_handler))
257        .route("/relay/v1/data", get(data_handler))
258        .route("/relay/v1/devices", get(devices_handler))
259        .route("/d/{*rest}", any(proxy_handler))
260        .layer(axum::middleware::from_fn_with_state(
261            limiter,
262            rate_limit_middleware,
263        ))
264        .with_state(state)
265}
266
267/// Take the relay's listening socket, before anything is announced.
268///
269/// Separate from serving for the reason [`crate::api::bind`] is: a caller that
270/// prints where the relay can be reached must be able to do it *after* the port
271/// is actually held. Announcing first and binding second made two lines false at
272/// once whenever the port was taken — a banner saying "listening on", a join
273/// command for a relay that does not exist, and then the failure. The gateway
274/// already had this split; the relay did not.
275pub async fn bind_relay(config: &RelayConfig) -> crate::Result<tokio::net::TcpListener> {
276    tokio::net::TcpListener::bind(config.bind)
277        .await
278        .map_err(crate::error::ShellTunnelError::Io)
279}
280
281/// Run the relay server until shutdown, binding its socket first.
282///
283/// Kept for callers that have nothing to print between the two steps. A caller
284/// that does — the binary, whose banner names the relay's address — should use
285/// [`bind_relay`] and [`serve_relay_on`] so the announcement follows the bind.
286pub async fn serve_relay(config: RelayConfig) -> crate::Result<()> {
287    let listener = bind_relay(&config).await?;
288    serve_relay_on(listener, config).await
289}
290
291/// Serve on an already-bound listener.
292pub async fn serve_relay_on(
293    listener: tokio::net::TcpListener,
294    config: RelayConfig,
295) -> crate::Result<()> {
296    let bind = config.bind;
297    #[cfg(feature = "tls")]
298    let tls = config.tls.clone();
299    let state = RelayState::new(config);
300    let router = relay_router(state.clone());
301
302    // A device that vanished without closing its socket looks identical to an
303    // idle one, so entries are reaped on heartbeat staleness instead.
304    let sweeper = state.devices().clone();
305    tokio::spawn(async move {
306        let mut ticker = tokio::time::interval(HEARTBEAT_TIMEOUT / 3);
307        loop {
308            ticker.tick().await;
309            for id in sweeper.evict_stale(HEARTBEAT_TIMEOUT) {
310                tracing::info!(target: "relay", device_id = %id, "device evicted (no heartbeat)");
311            }
312        }
313    });
314
315    // After the bind, not before: this line used to sit above it and said the
316    // relay was listening on a port it had not tried to take yet.
317    tracing::info!("relay listening on {}", bind);
318
319    // Connection info is what the rate limiter keys on; without it every caller
320    // would look identical.
321    let service = router.into_make_service_with_connect_info::<SocketAddr>();
322
323    #[cfg(feature = "tls")]
324    if let Some(files) = tls {
325        // Loaded before serving so a bad certificate stops startup rather than
326        // failing every connection at handshake time.
327        let config = crate::tls::acceptor(files.load()?);
328        // Renewal should not require a restart.
329        crate::tls::watch(files, config.clone());
330        let std_listener = listener.into_std().map_err(ShellTunnelError::Io)?;
331        return axum_server::from_tcp_rustls(std_listener, config)
332            .map_err(ShellTunnelError::Io)?
333            .serve(service)
334            .await
335            .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())));
336    }
337
338    axum::serve(listener, service)
339        .await
340        .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())))?;
341    Ok(())
342}
343
344/// Whether `name` is usable as a routing key in `/d/<name>/…`.
345///
346/// Deliberately narrow: the name lands in a URL path, so anything that could
347/// need escaping, traverse a path, or collide with the relay's own routes is
348/// rejected rather than sanitized.
349fn is_valid_device_name(name: &str) -> bool {
350    !name.is_empty()
351        && name.len() <= 64
352        && name
353            .chars()
354            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
355}
356
357/// Work out how this relay was addressed, from the connection's own headers.
358///
359/// A relay behind TLS termination sees plain HTTP on a loopback port, so the
360/// scheme and host it should advertise are only knowable from what the proxy
361/// forwards.
362fn observed_base(headers: &HeaderMap, tls: bool) -> Option<String> {
363    let host = headers
364        .get("x-forwarded-host")
365        .or_else(|| headers.get(axum::http::header::HOST))
366        .and_then(|value| value.to_str().ok())?;
367    if host.is_empty() {
368        return None;
369    }
370    // A proxy's own statement wins; failing that, the relay knows whether it
371    // terminated TLS itself. Guessing `http` while serving HTTPS would advertise
372    // a URL that the relay itself refuses.
373    let scheme = headers
374        .get("x-forwarded-proto")
375        .and_then(|value| value.to_str().ok())
376        .map(|proto| proto.split(',').next().unwrap_or(proto).trim().to_string())
377        .unwrap_or_else(|| if tls { "https" } else { "http" }.to_string());
378    Some(format!("{scheme}://{host}"))
379}
380
381/// Whether this relay terminates TLS itself.
382fn serves_tls(_state: &RelayState) -> bool {
383    #[cfg(feature = "tls")]
384    {
385        _state.config.tls.is_some()
386    }
387    #[cfg(not(feature = "tls"))]
388    {
389        false
390    }
391}
392
393/// Upgrade a device's outbound connection into the control channel.
394async fn control_handler(
395    ws: WebSocketUpgrade,
396    State(state): State<RelayState>,
397    ConnectInfo(peer): ConnectInfo<SocketAddr>,
398    charge: Option<Extension<RateLimitCharge>>,
399    headers: HeaderMap,
400) -> impl IntoResponse {
401    let observed = observed_base(&headers, serves_tls(&state));
402    let charge = charge.map(|Extension(charge)| charge);
403    ws.on_upgrade(move |socket| control_session(socket, state, observed, peer, charge))
404}
405
406/// Enroll a device, then serve its heartbeats until the connection ends.
407async fn control_session(
408    socket: WebSocket,
409    state: RelayState,
410    observed: Option<String>,
411    peer: SocketAddr,
412    charge: Option<RateLimitCharge>,
413) {
414    let (mut sink, mut stream) = socket.split();
415
416    // An unauthenticated peer must not be able to hold a connection open
417    // indefinitely, so enrollment is bounded in time.
418    let first = match tokio::time::timeout(ENROLL_TIMEOUT, stream.next()).await {
419        Ok(Some(Ok(Message::Text(text)))) => text,
420        _ => return,
421    };
422
423    let enroll = match serde_json::from_str::<DeviceMessage>(&first) {
424        Ok(DeviceMessage::Enroll {
425            enroll_token,
426            version,
427            label,
428            device_name,
429        }) => (enroll_token, version, label, device_name),
430        _ => {
431            reject_and_close(
432                &mut sink,
433                reject::BAD_HANDSHAKE,
434                "expected an enroll message",
435            )
436            .await;
437            return;
438        }
439    };
440    let (enroll_token, version, label, device_name) = enroll;
441
442    if version != PROTOCOL_VERSION {
443        reject_and_close(
444            &mut sink,
445            reject::UNSUPPORTED_VERSION,
446            &format!("relay speaks protocol version {PROTOCOL_VERSION}"),
447        )
448        .await;
449        return;
450    }
451
452    if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
453        // No detail about *why*: a device that guessed wrong learns nothing.
454        // The slot stays spent, which is the whole point of charging first:
455        // guesses are what the limit exists to slow down.
456        tracing::debug!(target: "relay", "enrollment rejected: bad token");
457        reject_and_close(&mut sink, reject::BAD_TOKEN, "enrollment refused").await;
458        return;
459    }
460
461    // Proven. Give the slot back — see `relay_router`.
462    if let Some(charge) = charge {
463        state.limiter.refund(peer.ip(), charge);
464    }
465
466    // A named device keeps one URL across reconnects, which is what makes the
467    // relay usable when whoever calls the device cannot read its console. An
468    // unnamed one gets a random id, which nobody can guess but which changes
469    // every time it attaches.
470    let device_id = match device_name {
471        Some(name) if !is_valid_device_name(&name) => {
472            reject_and_close(
473                &mut sink,
474                reject::BAD_DEVICE_NAME,
475                "device names may use letters, digits, '-' and '_' (1-64 characters)",
476            )
477            .await;
478            return;
479        }
480        // Re-attaching under an existing name replaces the old entry rather
481        // than being refused: after a network drop the relay still holds a
482        // connection it cannot know is dead, and refusing would lock the device
483        // out until the heartbeat timeout expired. Only holders of the enrol
484        // token can do this, which is the same trust level as attaching at all.
485        Some(name) => name,
486        None => generate_api_key(),
487    };
488    let public_url = state.config.public_url_for(&device_id, observed);
489    let registry::DeviceHandles {
490        device,
491        mut refill_rx,
492    } = state.devices.attach(&device_id, label.clone());
493    tracing::info!(
494        target: "relay",
495        device_id = %device_id,
496        label = label.as_deref().unwrap_or("-"),
497        "device attached"
498    );
499
500    let enrolled = RelayMessage::Enrolled {
501        device_id: device_id.clone(),
502        public_url,
503    };
504    if send_json(&mut sink, &enrolled).await.is_err() {
505        state.devices.detach(&device_id);
506        return;
507    }
508
509    // Fill the pool up front so the first request does not pay for a handshake.
510    let fill = RelayMessage::OpenData {
511        count: registry::POOL_TARGET,
512    };
513    if send_json(&mut sink, &fill).await.is_err() {
514        state.devices.detach(&device_id);
515        return;
516    }
517
518    // The control channel multiplexes nothing but coordination: device
519    // heartbeats one way, pool-refill requests the other.
520    loop {
521        tokio::select! {
522            incoming = stream.next() => {
523                let Some(Ok(message)) = incoming else { break };
524                match message {
525                    Message::Text(text) => match serde_json::from_str::<DeviceMessage>(&text) {
526                        Ok(DeviceMessage::Heartbeat) => {
527                            device.touch();
528                            if send_json(&mut sink, &RelayMessage::HeartbeatAck).await.is_err() {
529                                break;
530                            }
531                        }
532                        // A second enrollment on an attached connection is a
533                        // protocol error, not a re-key: ignore it rather than
534                        // reassigning an id.
535                        _ => continue,
536                    },
537                    Message::Close(_) => break,
538                    _ => continue,
539                }
540            }
541            refill = refill_rx.recv() => {
542                if refill.is_none() {
543                    break;
544                }
545                if send_json(&mut sink, &RelayMessage::OpenData { count: 1 }).await.is_err() {
546                    break;
547                }
548            }
549        }
550    }
551
552    state.devices.detach(&device_id);
553    tracing::info!(target: "relay", device_id = %device_id, "device detached");
554}
555
556/// List the devices currently attached.
557///
558/// Authenticated with the enrolment token, because the answer is only useful to
559/// whoever operates this relay — and anyone holding that token could attach a
560/// device anyway, so listing them reveals nothing new.
561async fn devices_handler(State(state): State<RelayState>, headers: HeaderMap) -> Response {
562    let presented = headers
563        .get(axum::http::header::AUTHORIZATION)
564        .and_then(|value| value.to_str().ok())
565        .and_then(|value| value.strip_prefix("Bearer "))
566        .unwrap_or("");
567    if !constant_time_eq(presented, &state.config.enroll_token) {
568        return StatusCode::UNAUTHORIZED.into_response();
569    }
570
571    let base = state
572        .config
573        .public_base_or(observed_base(&headers, serves_tls(&state)));
574    let devices: Vec<_> = state
575        .devices
576        .list()
577        .into_iter()
578        .map(|device| {
579            let url = format!("{}/d/{}", base, device.id);
580            // Serialised from the summary rather than field by field, so a
581            // field added there cannot be silently missing here. The timing
582            // fields are absent until a device has answered something.
583            let mut entry = serde_json::to_value(&device)
584                .unwrap_or_else(|_| serde_json::json!({ "id": device.id, "label": device.label }));
585            if let Some(object) = entry.as_object_mut() {
586                object.insert("public_url".to_string(), serde_json::Value::String(url));
587            }
588            entry
589        })
590        .collect();
591
592    axum::Json(serde_json::json!({ "devices": devices })).into_response()
593}
594
595/// Accept a data connection and park it in its device's pool.
596///
597/// The connection authenticates itself in its first frame rather than in the
598/// URL: query strings land in the access logs of the reverse proxies this relay
599/// is meant to sit behind, so a token there would be written to disk in
600/// plaintext on exactly the deployments that follow our own TLS advice.
601async fn data_handler(
602    ws: WebSocketUpgrade,
603    State(state): State<RelayState>,
604    ConnectInfo(peer): ConnectInfo<SocketAddr>,
605    charge: Option<Extension<RateLimitCharge>>,
606) -> Response {
607    let charge = charge.map(|Extension(charge)| charge);
608    // The reader is what refuses an oversized frame, and this is the reader for
609    // every response body a device sends. Stating the limit here rather than
610    // inheriting `tungstenite`'s default is what makes the published ceiling
611    // this crate's — see [`MAX_RELAY_FRAME`].
612    ws.max_frame_size(MAX_RELAY_FRAME)
613        .on_upgrade(move |socket| attach_data_connection(socket, state, peer, charge))
614}
615
616/// Read the attach frame, verify it, and hand the socket to the device's pool.
617async fn attach_data_connection(
618    mut socket: WebSocket,
619    state: RelayState,
620    peer: SocketAddr,
621    charge: Option<RateLimitCharge>,
622) {
623    let first = tokio::time::timeout(ENROLL_TIMEOUT, socket.recv()).await;
624    let Ok(Some(Ok(Message::Text(text)))) = first else {
625        let _ = socket.close().await;
626        return;
627    };
628
629    let Ok(DeviceMessage::Attach {
630        device_id,
631        enroll_token,
632    }) = serde_json::from_str::<DeviceMessage>(&text)
633    else {
634        let _ = socket.close().await;
635        return;
636    };
637
638    if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
639        tracing::debug!(target: "relay", "data connection rejected: bad token");
640        let _ = socket.close().await;
641        return;
642    }
643
644    // Proven. Give the slot back — see `relay_router`. This is the route that
645    // made the shared bucket a starvation risk rather than a curiosity: a
646    // device opens one of these per proxied request, so its volume is set by
647    // the public caller, not by the device.
648    if let Some(charge) = charge {
649        state.limiter.refund(peer.ip(), charge);
650    }
651
652    let Some(device) = state.devices.get(&device_id) else {
653        let _ = socket.close().await;
654        return;
655    };
656
657    // A pool that is already full means the device over-supplied; closing the
658    // extra socket is better than holding it open forever.
659    if let Some(mut extra) = device.offer(socket).await {
660        let _ = extra.close().await;
661    }
662}
663
664/// Forward a public request to the addressed device and return its response.
665async fn proxy_handler(State(state): State<RelayState>, request: Request) -> Response {
666    let path_and_query = request
667        .uri()
668        .path_and_query()
669        .map(|p| p.as_str().to_string())
670        .unwrap_or_else(|| request.uri().path().to_string());
671
672    let Some((device_id, tail)) = split_device_path(&path_and_query) else {
673        return StatusCode::NOT_FOUND.into_response();
674    };
675
676    let Some(device) = state.devices.get(device_id) else {
677        // The device is not attached: this is the relay reporting a missing
678        // upstream, which is exactly what 502 means.
679        return (StatusCode::BAD_GATEWAY, "device is not connected").into_response();
680    };
681
682    let method = request.method().to_string();
683    let headers: Vec<(String, String)> = request
684        .headers()
685        .iter()
686        .filter(|(name, _)| is_forwardable(name.as_str()))
687        .filter_map(|(name, value)| {
688            value
689                .to_str()
690                .ok()
691                .map(|v| (name.as_str().to_string(), v.to_string()))
692        })
693        .collect();
694
695    // A WebSocket upgrade cannot be answered by buffering: the exchange has no
696    // end until one side closes. Because one request already owns one data
697    // connection for its lifetime, the same socket simply becomes the pipe —
698    // the connection-per-request model pays off here rather than needing a
699    // second mechanism.
700    if is_websocket_upgrade(request.headers()) {
701        let (mut parts, _) = request.into_parts();
702        let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
703            Ok(upgrade) => upgrade,
704            Err(rejection) => return rejection.into_response(),
705        };
706        let proxied = ProxyRequest {
707            method,
708            path: tail,
709            headers,
710            websocket: true,
711        };
712        return upgrade.on_upgrade(move |client| pipe_websocket(client, device, proxied));
713    }
714
715    let body = match axum::body::to_bytes(request.into_body(), MAX_BODY).await {
716        Ok(body) => body,
717        Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
718    };
719
720    let Some(conn) = device.take(POOL_WAIT).await else {
721        // The device is attached but has no spare connection. 503 with a
722        // Retry-After is the honest answer: try again shortly.
723        return (
724            StatusCode::SERVICE_UNAVAILABLE,
725            [("retry-after", "1")],
726            "no data connection available",
727        )
728            .into_response();
729    };
730
731    // Timed from here, after the pool wait: what an operator asking "is this
732    // device slow?" needs is the device's answer time, and queueing for a free
733    // connection is the relay's own doing. Failures are recorded too — a device
734    // that times out is the slowest kind, and leaving those out would make the
735    // reported numbers look better the worse things got.
736    let started = std::time::Instant::now();
737    let outcome = tokio::time::timeout(
738        REQUEST_TIMEOUT,
739        forward(
740            conn,
741            ProxyRequest {
742                method,
743                path: tail,
744                headers,
745                websocket: false,
746            },
747            body,
748        ),
749    )
750    .await;
751    device.record_exchange(started.elapsed());
752
753    match outcome {
754        Ok(Ok(response)) => response,
755        Ok(Err(reason)) => {
756            tracing::debug!(target: "relay", device_id = %device.id, reason, "proxy failed");
757            (StatusCode::BAD_GATEWAY, "device did not answer").into_response()
758        }
759        Err(_) => (StatusCode::GATEWAY_TIMEOUT, "device timed out").into_response(),
760    }
761}
762
763/// Whether these headers ask to switch protocols to WebSocket.
764fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
765    let header_contains = |name: axum::http::HeaderName, needle: &str| {
766        headers
767            .get(name)
768            .and_then(|value| value.to_str().ok())
769            .is_some_and(|value| value.to_ascii_lowercase().contains(needle))
770    };
771    header_contains(axum::http::header::UPGRADE, "websocket")
772        && header_contains(axum::http::header::CONNECTION, "upgrade")
773}
774
775/// Join a client's WebSocket to the device over one data connection.
776///
777/// The relay has already answered 101 by the time this runs — axum completes the
778/// handshake before invoking the callback — so a device that then refuses simply
779/// results in the client's socket closing.
780async fn pipe_websocket(mut client: WebSocket, device: Arc<Device>, request: ProxyRequest) {
781    let Some(mut conn) = device.take(POOL_WAIT).await else {
782        tracing::debug!(target: "relay", device_id = %device.id, "no data connection for websocket");
783        let _ = client.close().await;
784        return;
785    };
786
787    let Ok(header) = serde_json::to_string(&request) else {
788        let _ = client.close().await;
789        return;
790    };
791    if conn.send(Message::Text(header.into())).await.is_err() {
792        let _ = client.close().await;
793        return;
794    }
795
796    // The device answers with the status its own server returned; anything but
797    // a switch means the upgrade did not happen there.
798    let switched = matches!(
799        conn.recv().await,
800        Some(Ok(Message::Text(ref text)))
801            if serde_json::from_str::<ProxyResponse>(text)
802                .map(|response| response.status == 101)
803                .unwrap_or(false)
804    );
805    if !switched {
806        let _ = client.close().await;
807        let _ = conn.close().await;
808        return;
809    }
810
811    // From here the two sockets are the same conversation: copy frames until
812    // either end hangs up.
813    loop {
814        tokio::select! {
815            from_client = client.recv() => {
816                match from_client {
817                    Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
818                    Some(Ok(message)) => {
819                        if conn.send(message).await.is_err() {
820                            break;
821                        }
822                    }
823                }
824            }
825            from_device = conn.recv() => {
826                match from_device {
827                    Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
828                    Some(Ok(message)) => {
829                        if client.send(message).await.is_err() {
830                            break;
831                        }
832                    }
833                }
834            }
835        }
836    }
837
838    let _ = client.close().await;
839    let _ = conn.close().await;
840}
841
842/// Largest request body the relay will buffer before forwarding.
843const MAX_BODY: usize = 8 * 1024 * 1024;
844
845/// Drive one request/response exchange over a dedicated data connection.
846///
847/// Wire shape: request header (text) → request body (binary) → response header
848/// (text) → response body (binary frames) → close.
849async fn forward(
850    mut conn: WebSocket,
851    request: ProxyRequest,
852    body: Bytes,
853) -> Result<Response, &'static str> {
854    let header = serde_json::to_string(&request).map_err(|_| "request-encode")?;
855    conn.send(Message::Text(header.into()))
856        .await
857        .map_err(|_| "request-header-send")?;
858    conn.send(Message::Binary(body))
859        .await
860        .map_err(|_| "request-body-send")?;
861
862    let head: ProxyResponse = loop {
863        match conn.recv().await {
864            Some(Ok(Message::Text(text))) => {
865                break serde_json::from_str(&text).map_err(|_| "response-decode")?
866            }
867            Some(Ok(_)) => continue,
868            _ => return Err("response-header-missing"),
869        }
870    };
871
872    // A read error here must not be mistaken for the end of the body. The
873    // device sends the response as a single binary frame, so a body over the
874    // WebSocket message limit fails the *read* — and `while let Some(Ok(_))`
875    // treats that failure exactly like a clean close, leaving `body` short (in
876    // practice empty) while the status, already taken from the header frame
877    // above, stays whatever the device answered. That shipped a truncated body
878    // under `200 OK`: silent data loss, reported as success. Live-verified at
879    // exactly 16 MiB against both `/execute` and a `fs/file` download.
880    let mut body = Vec::new();
881    loop {
882        match conn.recv().await {
883            Some(Ok(Message::Binary(chunk))) => body.extend_from_slice(&chunk),
884            Some(Ok(Message::Close(_))) | None => break,
885            Some(Ok(_)) => continue,
886            Some(Err(_)) => return Err("response-body-truncated"),
887        }
888    }
889
890    let mut response = Response::builder().status(head.status);
891    for (name, value) in head.headers {
892        if is_forwardable(&name) {
893            response = response.header(name, value);
894        }
895    }
896    response
897        .body(axum::body::Body::from(body))
898        .map_err(|_| "response-build")
899}
900
901/// Send a rejection and close, best-effort.
902async fn reject_and_close<S>(sink: &mut S, code: &str, message: &str)
903where
904    S: SinkExt<Message> + Unpin,
905{
906    let rejected = RelayMessage::Rejected {
907        code: code.to_string(),
908        message: message.to_string(),
909    };
910    let _ = send_json(sink, &rejected).await;
911    let _ = sink.close().await;
912}
913
914/// Serialize and send one protocol message.
915async fn send_json<S, T>(sink: &mut S, message: &T) -> Result<(), ()>
916where
917    S: SinkExt<Message> + Unpin,
918    T: serde::Serialize,
919{
920    let json = serde_json::to_string(message).map_err(|_| ())?;
921    sink.send(Message::Text(json.into())).await.map_err(|_| ())
922}
923
924/// Compare secrets without leaking their contents through timing.
925///
926/// The token is short and comparisons are rare, but an early-exit `==` on a
927/// shared secret is the kind of detail that is cheap to get right and awkward
928/// to retrofit.
929fn constant_time_eq(a: &str, b: &str) -> bool {
930    let (a, b) = (a.as_bytes(), b.as_bytes());
931    if a.len() != b.len() {
932        return false;
933    }
934    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
935}
936
937#[cfg(test)]
938mod tests {
939    use super::*;
940
941    fn config() -> RelayConfig {
942        RelayConfig::new("127.0.0.1:0".parse().unwrap(), "secret")
943    }
944
945    #[test]
946    fn a_portless_base_on_a_nondefault_port_gets_a_corrected_suggestion() {
947        // The failure this pins down: `--public-base https://labs.example.com`
948        // with the relay listening on 8443 printed join and device URLs that
949        // imply port 443, which nobody was serving. The hint is the corrected
950        // value to suggest — never applied silently, because a proxy or NAT
951        // forwarding 443 -> 8443 makes the portless form legitimate.
952        assert_eq!(
953            public_base_port_hint("https://labs.example.com", 8443).as_deref(),
954            Some("https://labs.example.com:8443")
955        );
956        assert_eq!(
957            public_base_port_hint("http://relay.local", 8080).as_deref(),
958            Some("http://relay.local:8080")
959        );
960    }
961
962    #[test]
963    fn a_base_matching_the_scheme_default_needs_no_hint() {
964        assert_eq!(public_base_port_hint("https://labs.example.com", 443), None);
965        assert_eq!(public_base_port_hint("http://relay.local", 80), None);
966    }
967
968    #[test]
969    fn an_explicit_port_is_the_operator_stating_intent() {
970        // Explicit ports are never second-guessed: a proxy may remap them.
971        assert_eq!(
972            public_base_port_hint("https://labs.example.com:8443", 8443),
973            None
974        );
975        assert_eq!(
976            public_base_port_hint("https://labs.example.com:9000", 8443),
977            None
978        );
979        assert_eq!(
980            public_base_port_hint("https://labs.example.com:443", 8443),
981            None
982        );
983    }
984
985    #[test]
986    fn the_port_is_spliced_into_the_authority_not_the_tail() {
987        // A base may carry a path prefix; the port belongs after the host.
988        assert_eq!(
989            public_base_port_hint("https://labs.example.com/relay", 8443).as_deref(),
990            Some("https://labs.example.com:8443/relay")
991        );
992    }
993
994    #[test]
995    fn ipv6_literals_look_for_the_port_after_the_bracket() {
996        assert_eq!(
997            public_base_port_hint("https://[::1]", 8443).as_deref(),
998            Some("https://[::1]:8443")
999        );
1000        assert_eq!(public_base_port_hint("https://[::1]:8443", 8443), None);
1001    }
1002
1003    #[test]
1004    fn an_unrecognized_scheme_is_left_alone() {
1005        assert_eq!(public_base_port_hint("ws://relay.local", 8443), None);
1006    }
1007
1008    #[test]
1009    fn public_url_uses_the_device_path_prefix() {
1010        // Bound to the https default port, so no port is spliced in and the test
1011        // stays about the path prefix and the trailing-slash trim.
1012        let config = RelayConfig::new("127.0.0.1:443".parse().unwrap(), "secret")
1013            .with_public_base("https://relay.example.com/");
1014        assert_eq!(
1015            config.public_url_for("dev-1", None),
1016            "https://relay.example.com/d/dev-1"
1017        );
1018    }
1019
1020    #[test]
1021    fn a_portless_base_inherits_the_listen_port() {
1022        // A안: the operator named the host but not the port and bound 8443 with
1023        // no proxy in sight, so every advertised URL uses 8443 — not the 443 a
1024        // bare `https://` would otherwise imply and nobody would be serving.
1025        let config = RelayConfig::new("0.0.0.0:8443".parse().unwrap(), "secret")
1026            .with_public_base("https://labs.example.com");
1027        assert_eq!(
1028            config.resolved_public_base().as_deref(),
1029            Some("https://labs.example.com:8443")
1030        );
1031        assert_eq!(
1032            config.public_url_for("dev-1", None),
1033            "https://labs.example.com:8443/d/dev-1"
1034        );
1035    }
1036
1037    #[test]
1038    fn an_explicit_port_survives_resolution() {
1039        // A reverse proxy on 443 forwarding to 8443 keeps a base that names 443;
1040        // the stated port is intent and is never rewritten to the listen port.
1041        let config = RelayConfig::new("0.0.0.0:8443".parse().unwrap(), "secret")
1042            .with_public_base("https://labs.example.com:443");
1043        assert_eq!(
1044            config.resolved_public_base().as_deref(),
1045            Some("https://labs.example.com:443")
1046        );
1047    }
1048
1049    #[test]
1050    fn resolution_leaves_a_default_port_base_alone() {
1051        // Listening on the scheme default means the bare base is already right.
1052        let config = RelayConfig::new("0.0.0.0:443".parse().unwrap(), "secret")
1053            .with_public_base("https://labs.example.com");
1054        assert_eq!(
1055            config.resolved_public_base().as_deref(),
1056            Some("https://labs.example.com")
1057        );
1058    }
1059
1060    #[test]
1061    fn public_base_defaults_to_the_bind_address() {
1062        let config = RelayConfig::new("127.0.0.1:8443".parse().unwrap(), "secret");
1063        assert_eq!(
1064            config.public_url_for("d", None),
1065            "http://127.0.0.1:8443/d/d"
1066        );
1067    }
1068
1069    #[test]
1070    fn an_observed_address_is_used_when_the_operator_configured_none() {
1071        let config = config();
1072        assert_eq!(
1073            config.public_url_for("dev-1", Some("https://relay.example.com".into())),
1074            "https://relay.example.com/d/dev-1"
1075        );
1076    }
1077
1078    #[test]
1079    fn a_configured_base_wins_over_what_the_connection_observed() {
1080        let config = RelayConfig::new("127.0.0.1:443".parse().unwrap(), "secret")
1081            .with_public_base("https://canonical.example");
1082        assert_eq!(
1083            config.public_url_for("dev-1", Some("https://whatever.invalid".into())),
1084            "https://canonical.example/d/dev-1"
1085        );
1086    }
1087
1088    #[test]
1089    fn the_forwarded_scheme_and_host_are_preferred_over_the_direct_host() {
1090        let mut headers = HeaderMap::new();
1091        headers.insert(axum::http::header::HOST, "127.0.0.1:8443".parse().unwrap());
1092        assert_eq!(
1093            observed_base(&headers, false).as_deref(),
1094            Some("http://127.0.0.1:8443")
1095        );
1096
1097        headers.insert("x-forwarded-proto", "https".parse().unwrap());
1098        headers.insert("x-forwarded-host", "relay.example.com".parse().unwrap());
1099        assert_eq!(
1100            observed_base(&headers, false).as_deref(),
1101            Some("https://relay.example.com")
1102        );
1103    }
1104
1105    #[test]
1106    fn a_proxy_chain_scheme_takes_the_first_entry() {
1107        let mut headers = HeaderMap::new();
1108        headers.insert(
1109            axum::http::header::HOST,
1110            "relay.example.com".parse().unwrap(),
1111        );
1112        headers.insert("x-forwarded-proto", "https, http".parse().unwrap());
1113        assert_eq!(
1114            observed_base(&headers, false).as_deref(),
1115            Some("https://relay.example.com")
1116        );
1117    }
1118
1119    #[test]
1120    fn no_host_header_means_nothing_observed() {
1121        assert!(observed_base(&HeaderMap::new(), false).is_none());
1122    }
1123
1124    #[test]
1125    fn terminating_tls_makes_the_advertised_url_https() {
1126        // Advertising http:// while refusing plaintext would hand out a URL the
1127        // relay itself rejects.
1128        let mut headers = HeaderMap::new();
1129        headers.insert(
1130            axum::http::header::HOST,
1131            "relay.example.com".parse().unwrap(),
1132        );
1133        assert_eq!(
1134            observed_base(&headers, true).as_deref(),
1135            Some("https://relay.example.com")
1136        );
1137    }
1138
1139    #[test]
1140    fn device_names_must_be_url_path_safe() {
1141        assert!(is_valid_device_name("build-box"));
1142        assert!(is_valid_device_name("laptop_2"));
1143        assert!(is_valid_device_name("a"));
1144
1145        assert!(!is_valid_device_name(""));
1146        assert!(!is_valid_device_name("has space"));
1147        assert!(!is_valid_device_name("../escape"));
1148        assert!(!is_valid_device_name("slash/inside"));
1149        assert!(!is_valid_device_name("querylike?x=1"));
1150        assert!(!is_valid_device_name(&"x".repeat(65)));
1151    }
1152
1153    #[test]
1154    fn constant_time_eq_matches_equality() {
1155        assert!(constant_time_eq("abc", "abc"));
1156        assert!(!constant_time_eq("abc", "abd"));
1157        assert!(!constant_time_eq("abc", "ab"));
1158        assert!(constant_time_eq("", ""));
1159    }
1160}