Skip to main content

shell_tunnel/relay/
mod.rs

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