Skip to main content

shell_tunnel/relay/
mod.rs

1//! Self-hosted relay: reaching a device that dialled out to you.
2//!
3//! The relay is the alternative to a third-party tunnel. A device opens one
4//! outbound WebSocket to it — no inbound port, no NAT configuration — and the
5//! relay routes public traffic back down that connection.
6//!
7//! It runs from the same binary (`shell-tunnel relay`), so an operator never
8//! has to match versions between two programs.
9//!
10//! What the relay deliberately does *not* do: interpret capability tokens.
11//! Enrollment decides which devices may attach; the capability token in each
12//! proxied request stays end-to-end between client and device. The relay is a
13//! router, not a second security boundary.
14
15#[cfg(feature = "relay-client")]
16pub mod client;
17pub mod protocol;
18pub mod proxy;
19pub mod registry;
20
21use std::net::SocketAddr;
22use std::sync::Arc;
23use std::time::Duration;
24
25use axum::{
26    body::Bytes,
27    extract::{
28        ws::{Message, WebSocket, WebSocketUpgrade},
29        FromRequestParts, Request, State,
30    },
31    http::{HeaderMap, StatusCode},
32    response::{IntoResponse, Response},
33    routing::{any, get},
34    Router,
35};
36use futures_util::{SinkExt, StreamExt};
37
38use crate::error::ShellTunnelError;
39use crate::security::generate_api_key;
40use protocol::{reject, DeviceMessage, RelayMessage, PROTOCOL_VERSION};
41use proxy::{
42    is_forwardable, split_device_path, ProxyRequest, ProxyResponse, POOL_WAIT, REQUEST_TIMEOUT,
43};
44use registry::{Device, DeviceRegistry};
45
46pub use registry::{DeviceRegistry as Registry, POOL_TARGET};
47
48/// How long a device may go without a heartbeat before it is considered gone.
49pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(90);
50
51/// How long to wait for the enrollment frame before dropping a connection.
52const ENROLL_TIMEOUT: Duration = Duration::from_secs(10);
53
54/// Relay server settings.
55#[derive(Debug, Clone)]
56pub struct RelayConfig {
57    /// Address to listen on.
58    pub bind: SocketAddr,
59    /// Secret a device must present to attach.
60    pub enroll_token: String,
61    /// Public base URL of this relay, when the operator states it explicitly.
62    ///
63    /// Left unset, the relay derives it from each connection's `Host` (and
64    /// `X-Forwarded-*`) headers, so a relay behind TLS termination still tells
65    /// devices an address that actually works.
66    pub public_base: Option<String>,
67}
68
69impl RelayConfig {
70    /// Create a configuration with the given bind address and token.
71    pub fn new(bind: SocketAddr, enroll_token: impl Into<String>) -> Self {
72        Self {
73            bind,
74            enroll_token: enroll_token.into(),
75            public_base: None,
76        }
77    }
78
79    /// Set the public base URL advertised to devices.
80    pub fn with_public_base(mut self, base: impl Into<String>) -> Self {
81        self.public_base = Some(base.into().trim_end_matches('/').to_string());
82        self
83    }
84
85    /// The base URL to advertise, preferring what the operator configured.
86    ///
87    /// `observed` is what the connection itself says this relay is reachable at.
88    /// Falling back to the bind address is a last resort — it is right only when
89    /// nothing is in front of the relay.
90    pub fn public_base_or(&self, observed: Option<String>) -> String {
91        self.public_base
92            .clone()
93            .or(observed)
94            .unwrap_or_else(|| format!("http://{}", self.bind))
95    }
96
97    /// Public URL that routes to `device_id`.
98    pub fn public_url_for(&self, device_id: &str, observed: Option<String>) -> String {
99        format!("{}/d/{}", self.public_base_or(observed), device_id)
100    }
101}
102
103/// Shared relay state.
104#[derive(Debug, Clone)]
105pub struct RelayState {
106    config: RelayConfig,
107    devices: DeviceRegistry,
108}
109
110impl RelayState {
111    /// Create state for `config`.
112    pub fn new(config: RelayConfig) -> Self {
113        Self {
114            config,
115            devices: DeviceRegistry::new(),
116        }
117    }
118
119    /// The device registry.
120    pub fn devices(&self) -> &DeviceRegistry {
121        &self.devices
122    }
123}
124
125/// Build the relay router.
126pub fn relay_router(state: RelayState) -> Router {
127    Router::new()
128        .route("/health", get(|| async { "OK" }))
129        .route("/relay/v1/control", get(control_handler))
130        .route("/relay/v1/data", get(data_handler))
131        .route("/relay/v1/devices", get(devices_handler))
132        .route("/d/{*rest}", any(proxy_handler))
133        .with_state(state)
134}
135
136/// Run the relay server until shutdown.
137pub async fn serve_relay(config: RelayConfig) -> crate::Result<()> {
138    let bind = config.bind;
139    let state = RelayState::new(config);
140    let router = relay_router(state.clone());
141
142    // A device that vanished without closing its socket looks identical to an
143    // idle one, so entries are reaped on heartbeat staleness instead.
144    let sweeper = state.devices().clone();
145    tokio::spawn(async move {
146        let mut ticker = tokio::time::interval(HEARTBEAT_TIMEOUT / 3);
147        loop {
148            ticker.tick().await;
149            for id in sweeper.evict_stale(HEARTBEAT_TIMEOUT) {
150                tracing::info!(target: "relay", device_id = %id, "device evicted (no heartbeat)");
151            }
152        }
153    });
154
155    tracing::info!("relay listening on {}", bind);
156
157    let listener = tokio::net::TcpListener::bind(bind)
158        .await
159        .map_err(ShellTunnelError::Io)?;
160    axum::serve(listener, router)
161        .await
162        .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())))?;
163    Ok(())
164}
165
166/// Whether `name` is usable as a routing key in `/d/<name>/…`.
167///
168/// Deliberately narrow: the name lands in a URL path, so anything that could
169/// need escaping, traverse a path, or collide with the relay's own routes is
170/// rejected rather than sanitized.
171fn is_valid_device_name(name: &str) -> bool {
172    !name.is_empty()
173        && name.len() <= 64
174        && name
175            .chars()
176            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
177}
178
179/// Work out how this relay was addressed, from the connection's own headers.
180///
181/// A relay behind TLS termination sees plain HTTP on a loopback port, so the
182/// scheme and host it should advertise are only knowable from what the proxy
183/// forwards.
184fn observed_base(headers: &HeaderMap) -> Option<String> {
185    let host = headers
186        .get("x-forwarded-host")
187        .or_else(|| headers.get(axum::http::header::HOST))
188        .and_then(|value| value.to_str().ok())?;
189    if host.is_empty() {
190        return None;
191    }
192    let scheme = headers
193        .get("x-forwarded-proto")
194        .and_then(|value| value.to_str().ok())
195        .map(|proto| proto.split(',').next().unwrap_or(proto).trim().to_string())
196        .unwrap_or_else(|| "http".to_string());
197    Some(format!("{scheme}://{host}"))
198}
199
200/// Upgrade a device's outbound connection into the control channel.
201async fn control_handler(
202    ws: WebSocketUpgrade,
203    State(state): State<RelayState>,
204    headers: HeaderMap,
205) -> impl IntoResponse {
206    let observed = observed_base(&headers);
207    ws.on_upgrade(move |socket| control_session(socket, state, observed))
208}
209
210/// Enroll a device, then serve its heartbeats until the connection ends.
211async fn control_session(socket: WebSocket, state: RelayState, observed: Option<String>) {
212    let (mut sink, mut stream) = socket.split();
213
214    // An unauthenticated peer must not be able to hold a connection open
215    // indefinitely, so enrollment is bounded in time.
216    let first = match tokio::time::timeout(ENROLL_TIMEOUT, stream.next()).await {
217        Ok(Some(Ok(Message::Text(text)))) => text,
218        _ => return,
219    };
220
221    let enroll = match serde_json::from_str::<DeviceMessage>(&first) {
222        Ok(DeviceMessage::Enroll {
223            enroll_token,
224            version,
225            label,
226            device_name,
227        }) => (enroll_token, version, label, device_name),
228        _ => {
229            reject_and_close(
230                &mut sink,
231                reject::BAD_HANDSHAKE,
232                "expected an enroll message",
233            )
234            .await;
235            return;
236        }
237    };
238    let (enroll_token, version, label, device_name) = enroll;
239
240    if version != PROTOCOL_VERSION {
241        reject_and_close(
242            &mut sink,
243            reject::UNSUPPORTED_VERSION,
244            &format!("relay speaks protocol version {PROTOCOL_VERSION}"),
245        )
246        .await;
247        return;
248    }
249
250    if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
251        // No detail about *why*: a device that guessed wrong learns nothing.
252        tracing::debug!(target: "relay", "enrollment rejected: bad token");
253        reject_and_close(&mut sink, reject::BAD_TOKEN, "enrollment refused").await;
254        return;
255    }
256
257    // A named device keeps one URL across reconnects, which is what makes the
258    // relay usable when whoever calls the device cannot read its console. An
259    // unnamed one gets a random id, which nobody can guess but which changes
260    // every time it attaches.
261    let device_id = match device_name {
262        Some(name) if !is_valid_device_name(&name) => {
263            reject_and_close(
264                &mut sink,
265                reject::BAD_DEVICE_NAME,
266                "device names may use letters, digits, '-' and '_' (1-64 characters)",
267            )
268            .await;
269            return;
270        }
271        // Re-attaching under an existing name replaces the old entry rather
272        // than being refused: after a network drop the relay still holds a
273        // connection it cannot know is dead, and refusing would lock the device
274        // out until the heartbeat timeout expired. Only holders of the enrol
275        // token can do this, which is the same trust level as attaching at all.
276        Some(name) => name,
277        None => generate_api_key(),
278    };
279    let public_url = state.config.public_url_for(&device_id, observed);
280    let registry::DeviceHandles {
281        device,
282        mut refill_rx,
283    } = state.devices.attach(&device_id, label.clone());
284    tracing::info!(
285        target: "relay",
286        device_id = %device_id,
287        label = label.as_deref().unwrap_or("-"),
288        "device attached"
289    );
290
291    let enrolled = RelayMessage::Enrolled {
292        device_id: device_id.clone(),
293        public_url,
294    };
295    if send_json(&mut sink, &enrolled).await.is_err() {
296        state.devices.detach(&device_id);
297        return;
298    }
299
300    // Fill the pool up front so the first request does not pay for a handshake.
301    let fill = RelayMessage::OpenData {
302        count: registry::POOL_TARGET,
303    };
304    if send_json(&mut sink, &fill).await.is_err() {
305        state.devices.detach(&device_id);
306        return;
307    }
308
309    // The control channel multiplexes nothing but coordination: device
310    // heartbeats one way, pool-refill requests the other.
311    loop {
312        tokio::select! {
313            incoming = stream.next() => {
314                let Some(Ok(message)) = incoming else { break };
315                match message {
316                    Message::Text(text) => match serde_json::from_str::<DeviceMessage>(&text) {
317                        Ok(DeviceMessage::Heartbeat) => {
318                            device.touch();
319                            if send_json(&mut sink, &RelayMessage::HeartbeatAck).await.is_err() {
320                                break;
321                            }
322                        }
323                        // A second enrollment on an attached connection is a
324                        // protocol error, not a re-key: ignore it rather than
325                        // reassigning an id.
326                        _ => continue,
327                    },
328                    Message::Close(_) => break,
329                    _ => continue,
330                }
331            }
332            refill = refill_rx.recv() => {
333                if refill.is_none() {
334                    break;
335                }
336                if send_json(&mut sink, &RelayMessage::OpenData { count: 1 }).await.is_err() {
337                    break;
338                }
339            }
340        }
341    }
342
343    state.devices.detach(&device_id);
344    tracing::info!(target: "relay", device_id = %device_id, "device detached");
345}
346
347/// List the devices currently attached.
348///
349/// Authenticated with the enrolment token, because the answer is only useful to
350/// whoever operates this relay — and anyone holding that token could attach a
351/// device anyway, so listing them reveals nothing new.
352async fn devices_handler(State(state): State<RelayState>, headers: HeaderMap) -> Response {
353    let presented = headers
354        .get(axum::http::header::AUTHORIZATION)
355        .and_then(|value| value.to_str().ok())
356        .and_then(|value| value.strip_prefix("Bearer "))
357        .unwrap_or("");
358    if !constant_time_eq(presented, &state.config.enroll_token) {
359        return StatusCode::UNAUTHORIZED.into_response();
360    }
361
362    let base = state.config.public_base_or(observed_base(&headers));
363    let devices: Vec<_> = state
364        .devices
365        .list()
366        .into_iter()
367        .map(|device| {
368            let url = format!("{}/d/{}", base, device.id);
369            serde_json::json!({
370                "id": device.id,
371                "label": device.label,
372                "attached_secs": device.attached_secs,
373                "last_seen_secs": device.last_seen_secs,
374                "public_url": url,
375            })
376        })
377        .collect();
378
379    axum::Json(serde_json::json!({ "devices": devices })).into_response()
380}
381
382/// Accept a data connection and park it in its device's pool.
383///
384/// The connection authenticates itself in its first frame rather than in the
385/// URL: query strings land in the access logs of the reverse proxies this relay
386/// is meant to sit behind, so a token there would be written to disk in
387/// plaintext on exactly the deployments that follow our own TLS advice.
388async fn data_handler(ws: WebSocketUpgrade, State(state): State<RelayState>) -> Response {
389    ws.on_upgrade(move |socket| attach_data_connection(socket, state))
390}
391
392/// Read the attach frame, verify it, and hand the socket to the device's pool.
393async fn attach_data_connection(mut socket: WebSocket, state: RelayState) {
394    let first = tokio::time::timeout(ENROLL_TIMEOUT, socket.recv()).await;
395    let Ok(Some(Ok(Message::Text(text)))) = first else {
396        let _ = socket.close().await;
397        return;
398    };
399
400    let Ok(DeviceMessage::Attach {
401        device_id,
402        enroll_token,
403    }) = serde_json::from_str::<DeviceMessage>(&text)
404    else {
405        let _ = socket.close().await;
406        return;
407    };
408
409    if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
410        tracing::debug!(target: "relay", "data connection rejected: bad token");
411        let _ = socket.close().await;
412        return;
413    }
414
415    let Some(device) = state.devices.get(&device_id) else {
416        let _ = socket.close().await;
417        return;
418    };
419
420    // A pool that is already full means the device over-supplied; closing the
421    // extra socket is better than holding it open forever.
422    if let Some(mut extra) = device.offer(socket).await {
423        let _ = extra.close().await;
424    }
425}
426
427/// Forward a public request to the addressed device and return its response.
428async fn proxy_handler(State(state): State<RelayState>, request: Request) -> Response {
429    let path_and_query = request
430        .uri()
431        .path_and_query()
432        .map(|p| p.as_str().to_string())
433        .unwrap_or_else(|| request.uri().path().to_string());
434
435    let Some((device_id, tail)) = split_device_path(&path_and_query) else {
436        return StatusCode::NOT_FOUND.into_response();
437    };
438
439    let Some(device) = state.devices.get(device_id) else {
440        // The device is not attached: this is the relay reporting a missing
441        // upstream, which is exactly what 502 means.
442        return (StatusCode::BAD_GATEWAY, "device is not connected").into_response();
443    };
444
445    let method = request.method().to_string();
446    let headers: Vec<(String, String)> = request
447        .headers()
448        .iter()
449        .filter(|(name, _)| is_forwardable(name.as_str()))
450        .filter_map(|(name, value)| {
451            value
452                .to_str()
453                .ok()
454                .map(|v| (name.as_str().to_string(), v.to_string()))
455        })
456        .collect();
457
458    // A WebSocket upgrade cannot be answered by buffering: the exchange has no
459    // end until one side closes. Because one request already owns one data
460    // connection for its lifetime, the same socket simply becomes the pipe —
461    // the connection-per-request model pays off here rather than needing a
462    // second mechanism.
463    if is_websocket_upgrade(request.headers()) {
464        let (mut parts, _) = request.into_parts();
465        let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
466            Ok(upgrade) => upgrade,
467            Err(rejection) => return rejection.into_response(),
468        };
469        let proxied = ProxyRequest {
470            method,
471            path: tail,
472            headers,
473            websocket: true,
474        };
475        return upgrade.on_upgrade(move |client| pipe_websocket(client, device, proxied));
476    }
477
478    let body = match axum::body::to_bytes(request.into_body(), MAX_BODY).await {
479        Ok(body) => body,
480        Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
481    };
482
483    let Some(conn) = device.take(POOL_WAIT).await else {
484        // The device is attached but has no spare connection. 503 with a
485        // Retry-After is the honest answer: try again shortly.
486        return (
487            StatusCode::SERVICE_UNAVAILABLE,
488            [("retry-after", "1")],
489            "no data connection available",
490        )
491            .into_response();
492    };
493
494    match tokio::time::timeout(
495        REQUEST_TIMEOUT,
496        forward(
497            conn,
498            ProxyRequest {
499                method,
500                path: tail,
501                headers,
502                websocket: false,
503            },
504            body,
505        ),
506    )
507    .await
508    {
509        Ok(Ok(response)) => response,
510        Ok(Err(reason)) => {
511            tracing::debug!(target: "relay", device_id = %device.id, reason, "proxy failed");
512            (StatusCode::BAD_GATEWAY, "device did not answer").into_response()
513        }
514        Err(_) => (StatusCode::GATEWAY_TIMEOUT, "device timed out").into_response(),
515    }
516}
517
518/// Whether these headers ask to switch protocols to WebSocket.
519fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
520    let header_contains = |name: axum::http::HeaderName, needle: &str| {
521        headers
522            .get(name)
523            .and_then(|value| value.to_str().ok())
524            .is_some_and(|value| value.to_ascii_lowercase().contains(needle))
525    };
526    header_contains(axum::http::header::UPGRADE, "websocket")
527        && header_contains(axum::http::header::CONNECTION, "upgrade")
528}
529
530/// Join a client's WebSocket to the device over one data connection.
531///
532/// The relay has already answered 101 by the time this runs — axum completes the
533/// handshake before invoking the callback — so a device that then refuses simply
534/// results in the client's socket closing.
535async fn pipe_websocket(mut client: WebSocket, device: Arc<Device>, request: ProxyRequest) {
536    let Some(mut conn) = device.take(POOL_WAIT).await else {
537        tracing::debug!(target: "relay", device_id = %device.id, "no data connection for websocket");
538        let _ = client.close().await;
539        return;
540    };
541
542    let Ok(header) = serde_json::to_string(&request) else {
543        let _ = client.close().await;
544        return;
545    };
546    if conn.send(Message::Text(header.into())).await.is_err() {
547        let _ = client.close().await;
548        return;
549    }
550
551    // The device answers with the status its own server returned; anything but
552    // a switch means the upgrade did not happen there.
553    let switched = matches!(
554        conn.recv().await,
555        Some(Ok(Message::Text(ref text)))
556            if serde_json::from_str::<ProxyResponse>(text)
557                .map(|response| response.status == 101)
558                .unwrap_or(false)
559    );
560    if !switched {
561        let _ = client.close().await;
562        let _ = conn.close().await;
563        return;
564    }
565
566    // From here the two sockets are the same conversation: copy frames until
567    // either end hangs up.
568    loop {
569        tokio::select! {
570            from_client = client.recv() => {
571                match from_client {
572                    Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
573                    Some(Ok(message)) => {
574                        if conn.send(message).await.is_err() {
575                            break;
576                        }
577                    }
578                }
579            }
580            from_device = conn.recv() => {
581                match from_device {
582                    Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
583                    Some(Ok(message)) => {
584                        if client.send(message).await.is_err() {
585                            break;
586                        }
587                    }
588                }
589            }
590        }
591    }
592
593    let _ = client.close().await;
594    let _ = conn.close().await;
595}
596
597/// Largest request body the relay will buffer before forwarding.
598const MAX_BODY: usize = 8 * 1024 * 1024;
599
600/// Drive one request/response exchange over a dedicated data connection.
601///
602/// Wire shape: request header (text) → request body (binary) → response header
603/// (text) → response body (binary frames) → close.
604async fn forward(
605    mut conn: WebSocket,
606    request: ProxyRequest,
607    body: Bytes,
608) -> Result<Response, &'static str> {
609    let header = serde_json::to_string(&request).map_err(|_| "request-encode")?;
610    conn.send(Message::Text(header.into()))
611        .await
612        .map_err(|_| "request-header-send")?;
613    conn.send(Message::Binary(body))
614        .await
615        .map_err(|_| "request-body-send")?;
616
617    let head: ProxyResponse = loop {
618        match conn.recv().await {
619            Some(Ok(Message::Text(text))) => {
620                break serde_json::from_str(&text).map_err(|_| "response-decode")?
621            }
622            Some(Ok(_)) => continue,
623            _ => return Err("response-header-missing"),
624        }
625    };
626
627    let mut body = Vec::new();
628    while let Some(Ok(message)) = conn.recv().await {
629        match message {
630            Message::Binary(chunk) => body.extend_from_slice(&chunk),
631            Message::Close(_) => break,
632            _ => continue,
633        }
634    }
635
636    let mut response = Response::builder().status(head.status);
637    for (name, value) in head.headers {
638        if is_forwardable(&name) {
639            response = response.header(name, value);
640        }
641    }
642    response
643        .body(axum::body::Body::from(body))
644        .map_err(|_| "response-build")
645}
646
647/// Send a rejection and close, best-effort.
648async fn reject_and_close<S>(sink: &mut S, code: &str, message: &str)
649where
650    S: SinkExt<Message> + Unpin,
651{
652    let rejected = RelayMessage::Rejected {
653        code: code.to_string(),
654        message: message.to_string(),
655    };
656    let _ = send_json(sink, &rejected).await;
657    let _ = sink.close().await;
658}
659
660/// Serialize and send one protocol message.
661async fn send_json<S, T>(sink: &mut S, message: &T) -> Result<(), ()>
662where
663    S: SinkExt<Message> + Unpin,
664    T: serde::Serialize,
665{
666    let json = serde_json::to_string(message).map_err(|_| ())?;
667    sink.send(Message::Text(json.into())).await.map_err(|_| ())
668}
669
670/// Compare secrets without leaking their contents through timing.
671///
672/// The token is short and comparisons are rare, but an early-exit `==` on a
673/// shared secret is the kind of detail that is cheap to get right and awkward
674/// to retrofit.
675fn constant_time_eq(a: &str, b: &str) -> bool {
676    let (a, b) = (a.as_bytes(), b.as_bytes());
677    if a.len() != b.len() {
678        return false;
679    }
680    a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
681}
682
683#[cfg(test)]
684mod tests {
685    use super::*;
686
687    fn config() -> RelayConfig {
688        RelayConfig::new("127.0.0.1:0".parse().unwrap(), "secret")
689    }
690
691    #[test]
692    fn public_url_uses_the_device_path_prefix() {
693        let config = config().with_public_base("https://relay.example.com/");
694        assert_eq!(
695            config.public_url_for("dev-1", None),
696            "https://relay.example.com/d/dev-1"
697        );
698    }
699
700    #[test]
701    fn public_base_defaults_to_the_bind_address() {
702        let config = RelayConfig::new("127.0.0.1:8443".parse().unwrap(), "secret");
703        assert_eq!(
704            config.public_url_for("d", None),
705            "http://127.0.0.1:8443/d/d"
706        );
707    }
708
709    #[test]
710    fn an_observed_address_is_used_when_the_operator_configured_none() {
711        let config = config();
712        assert_eq!(
713            config.public_url_for("dev-1", Some("https://relay.example.com".into())),
714            "https://relay.example.com/d/dev-1"
715        );
716    }
717
718    #[test]
719    fn a_configured_base_wins_over_what_the_connection_observed() {
720        let config = config().with_public_base("https://canonical.example");
721        assert_eq!(
722            config.public_url_for("dev-1", Some("https://whatever.invalid".into())),
723            "https://canonical.example/d/dev-1"
724        );
725    }
726
727    #[test]
728    fn the_forwarded_scheme_and_host_are_preferred_over_the_direct_host() {
729        let mut headers = HeaderMap::new();
730        headers.insert(axum::http::header::HOST, "127.0.0.1:8443".parse().unwrap());
731        assert_eq!(
732            observed_base(&headers).as_deref(),
733            Some("http://127.0.0.1:8443")
734        );
735
736        headers.insert("x-forwarded-proto", "https".parse().unwrap());
737        headers.insert("x-forwarded-host", "relay.example.com".parse().unwrap());
738        assert_eq!(
739            observed_base(&headers).as_deref(),
740            Some("https://relay.example.com")
741        );
742    }
743
744    #[test]
745    fn a_proxy_chain_scheme_takes_the_first_entry() {
746        let mut headers = HeaderMap::new();
747        headers.insert(
748            axum::http::header::HOST,
749            "relay.example.com".parse().unwrap(),
750        );
751        headers.insert("x-forwarded-proto", "https, http".parse().unwrap());
752        assert_eq!(
753            observed_base(&headers).as_deref(),
754            Some("https://relay.example.com")
755        );
756    }
757
758    #[test]
759    fn no_host_header_means_nothing_observed() {
760        assert!(observed_base(&HeaderMap::new()).is_none());
761    }
762
763    #[test]
764    fn device_names_must_be_url_path_safe() {
765        assert!(is_valid_device_name("build-box"));
766        assert!(is_valid_device_name("laptop_2"));
767        assert!(is_valid_device_name("a"));
768
769        assert!(!is_valid_device_name(""));
770        assert!(!is_valid_device_name("has space"));
771        assert!(!is_valid_device_name("../escape"));
772        assert!(!is_valid_device_name("slash/inside"));
773        assert!(!is_valid_device_name("querylike?x=1"));
774        assert!(!is_valid_device_name(&"x".repeat(65)));
775    }
776
777    #[test]
778    fn constant_time_eq_matches_equality() {
779        assert!(constant_time_eq("abc", "abc"));
780        assert!(!constant_time_eq("abc", "abd"));
781        assert!(!constant_time_eq("abc", "ab"));
782        assert!(constant_time_eq("", ""));
783    }
784}