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