1#[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
48pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(90);
50
51const ENROLL_TIMEOUT: Duration = Duration::from_secs(10);
53
54#[derive(Debug, Clone)]
56pub struct RelayConfig {
57 pub bind: SocketAddr,
59 pub enroll_token: String,
61 pub rate_limit: RateLimitConfig,
68 #[cfg(feature = "tls")]
70 pub tls: Option<crate::tls::TlsFiles>,
71 pub public_base: Option<String>,
77}
78
79impl RelayConfig {
80 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 #[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 pub fn without_rate_limit(mut self) -> Self {
101 self.rate_limit.enabled = false;
102 self
103 }
104
105 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 pub fn public_base_or(&self, observed: Option<String>) -> String {
117 self.public_base
118 .clone()
119 .or(observed)
120 .unwrap_or_else(|| format!("http://{}", self.bind))
121 }
122
123 pub fn public_url_for(&self, device_id: &str, observed: Option<String>) -> String {
125 format!("{}/d/{}", self.public_base_or(observed), device_id)
126 }
127}
128
129pub fn public_base_port_hint(base: &str, listen_port: u16) -> Option<String> {
139 let (scheme, rest) = base.split_once("://")?;
140 let default_port: u16 = match scheme {
141 "https" => 443,
142 "http" => 80,
143 _ => return None,
144 };
145 if listen_port == default_port {
146 return None;
147 }
148 let authority_end = rest.find('/').unwrap_or(rest.len());
149 let authority = &rest[..authority_end];
150 let has_port = match authority.rfind(']') {
153 Some(bracket) => authority[bracket..].contains(':'),
154 None => authority.contains(':'),
155 };
156 if has_port {
157 return None;
158 }
159 Some(format!(
160 "{scheme}://{authority}:{listen_port}{}",
161 &rest[authority_end..]
162 ))
163}
164
165#[derive(Debug, Clone)]
167pub struct RelayState {
168 config: RelayConfig,
169 devices: DeviceRegistry,
170}
171
172impl RelayState {
173 pub fn new(config: RelayConfig) -> Self {
175 Self {
176 config,
177 devices: DeviceRegistry::new(),
178 }
179 }
180
181 pub fn devices(&self) -> &DeviceRegistry {
183 &self.devices
184 }
185}
186
187pub fn relay_router(state: RelayState) -> Router {
193 let limiter = Arc::new(RateLimiter::new(state.config.rate_limit.clone()));
194
195 Router::new()
196 .route("/health", get(|| async { "OK" }))
197 .route("/relay/v1/control", get(control_handler))
198 .route("/relay/v1/data", get(data_handler))
199 .route("/relay/v1/devices", get(devices_handler))
200 .route("/d/{*rest}", any(proxy_handler))
201 .layer(axum::middleware::from_fn_with_state(
202 limiter,
203 rate_limit_middleware,
204 ))
205 .with_state(state)
206}
207
208pub async fn serve_relay(config: RelayConfig) -> crate::Result<()> {
210 let bind = config.bind;
211 #[cfg(feature = "tls")]
212 let tls = config.tls.clone();
213 let state = RelayState::new(config);
214 let router = relay_router(state.clone());
215
216 let sweeper = state.devices().clone();
219 tokio::spawn(async move {
220 let mut ticker = tokio::time::interval(HEARTBEAT_TIMEOUT / 3);
221 loop {
222 ticker.tick().await;
223 for id in sweeper.evict_stale(HEARTBEAT_TIMEOUT) {
224 tracing::info!(target: "relay", device_id = %id, "device evicted (no heartbeat)");
225 }
226 }
227 });
228
229 tracing::info!("relay listening on {}", bind);
230
231 let listener = tokio::net::TcpListener::bind(bind)
232 .await
233 .map_err(ShellTunnelError::Io)?;
234 let service = router.into_make_service_with_connect_info::<SocketAddr>();
237
238 #[cfg(feature = "tls")]
239 if let Some(files) = tls {
240 let config = crate::tls::acceptor(files.load()?);
243 crate::tls::watch(files, config.clone());
245 let std_listener = listener.into_std().map_err(ShellTunnelError::Io)?;
246 return axum_server::from_tcp_rustls(std_listener, config)
247 .map_err(ShellTunnelError::Io)?
248 .serve(service)
249 .await
250 .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())));
251 }
252
253 axum::serve(listener, service)
254 .await
255 .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())))?;
256 Ok(())
257}
258
259fn is_valid_device_name(name: &str) -> bool {
265 !name.is_empty()
266 && name.len() <= 64
267 && name
268 .chars()
269 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
270}
271
272fn observed_base(headers: &HeaderMap, tls: bool) -> Option<String> {
278 let host = headers
279 .get("x-forwarded-host")
280 .or_else(|| headers.get(axum::http::header::HOST))
281 .and_then(|value| value.to_str().ok())?;
282 if host.is_empty() {
283 return None;
284 }
285 let scheme = headers
289 .get("x-forwarded-proto")
290 .and_then(|value| value.to_str().ok())
291 .map(|proto| proto.split(',').next().unwrap_or(proto).trim().to_string())
292 .unwrap_or_else(|| if tls { "https" } else { "http" }.to_string());
293 Some(format!("{scheme}://{host}"))
294}
295
296fn serves_tls(_state: &RelayState) -> bool {
298 #[cfg(feature = "tls")]
299 {
300 _state.config.tls.is_some()
301 }
302 #[cfg(not(feature = "tls"))]
303 {
304 false
305 }
306}
307
308async fn control_handler(
310 ws: WebSocketUpgrade,
311 State(state): State<RelayState>,
312 headers: HeaderMap,
313) -> impl IntoResponse {
314 let observed = observed_base(&headers, serves_tls(&state));
315 ws.on_upgrade(move |socket| control_session(socket, state, observed))
316}
317
318async fn control_session(socket: WebSocket, state: RelayState, observed: Option<String>) {
320 let (mut sink, mut stream) = socket.split();
321
322 let first = match tokio::time::timeout(ENROLL_TIMEOUT, stream.next()).await {
325 Ok(Some(Ok(Message::Text(text)))) => text,
326 _ => return,
327 };
328
329 let enroll = match serde_json::from_str::<DeviceMessage>(&first) {
330 Ok(DeviceMessage::Enroll {
331 enroll_token,
332 version,
333 label,
334 device_name,
335 }) => (enroll_token, version, label, device_name),
336 _ => {
337 reject_and_close(
338 &mut sink,
339 reject::BAD_HANDSHAKE,
340 "expected an enroll message",
341 )
342 .await;
343 return;
344 }
345 };
346 let (enroll_token, version, label, device_name) = enroll;
347
348 if version != PROTOCOL_VERSION {
349 reject_and_close(
350 &mut sink,
351 reject::UNSUPPORTED_VERSION,
352 &format!("relay speaks protocol version {PROTOCOL_VERSION}"),
353 )
354 .await;
355 return;
356 }
357
358 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
359 tracing::debug!(target: "relay", "enrollment rejected: bad token");
361 reject_and_close(&mut sink, reject::BAD_TOKEN, "enrollment refused").await;
362 return;
363 }
364
365 let device_id = match device_name {
370 Some(name) if !is_valid_device_name(&name) => {
371 reject_and_close(
372 &mut sink,
373 reject::BAD_DEVICE_NAME,
374 "device names may use letters, digits, '-' and '_' (1-64 characters)",
375 )
376 .await;
377 return;
378 }
379 Some(name) => name,
385 None => generate_api_key(),
386 };
387 let public_url = state.config.public_url_for(&device_id, observed);
388 let registry::DeviceHandles {
389 device,
390 mut refill_rx,
391 } = state.devices.attach(&device_id, label.clone());
392 tracing::info!(
393 target: "relay",
394 device_id = %device_id,
395 label = label.as_deref().unwrap_or("-"),
396 "device attached"
397 );
398
399 let enrolled = RelayMessage::Enrolled {
400 device_id: device_id.clone(),
401 public_url,
402 };
403 if send_json(&mut sink, &enrolled).await.is_err() {
404 state.devices.detach(&device_id);
405 return;
406 }
407
408 let fill = RelayMessage::OpenData {
410 count: registry::POOL_TARGET,
411 };
412 if send_json(&mut sink, &fill).await.is_err() {
413 state.devices.detach(&device_id);
414 return;
415 }
416
417 loop {
420 tokio::select! {
421 incoming = stream.next() => {
422 let Some(Ok(message)) = incoming else { break };
423 match message {
424 Message::Text(text) => match serde_json::from_str::<DeviceMessage>(&text) {
425 Ok(DeviceMessage::Heartbeat) => {
426 device.touch();
427 if send_json(&mut sink, &RelayMessage::HeartbeatAck).await.is_err() {
428 break;
429 }
430 }
431 _ => continue,
435 },
436 Message::Close(_) => break,
437 _ => continue,
438 }
439 }
440 refill = refill_rx.recv() => {
441 if refill.is_none() {
442 break;
443 }
444 if send_json(&mut sink, &RelayMessage::OpenData { count: 1 }).await.is_err() {
445 break;
446 }
447 }
448 }
449 }
450
451 state.devices.detach(&device_id);
452 tracing::info!(target: "relay", device_id = %device_id, "device detached");
453}
454
455async fn devices_handler(State(state): State<RelayState>, headers: HeaderMap) -> Response {
461 let presented = headers
462 .get(axum::http::header::AUTHORIZATION)
463 .and_then(|value| value.to_str().ok())
464 .and_then(|value| value.strip_prefix("Bearer "))
465 .unwrap_or("");
466 if !constant_time_eq(presented, &state.config.enroll_token) {
467 return StatusCode::UNAUTHORIZED.into_response();
468 }
469
470 let base = state
471 .config
472 .public_base_or(observed_base(&headers, serves_tls(&state)));
473 let devices: Vec<_> = state
474 .devices
475 .list()
476 .into_iter()
477 .map(|device| {
478 let url = format!("{}/d/{}", base, device.id);
479 serde_json::json!({
480 "id": device.id,
481 "label": device.label,
482 "attached_secs": device.attached_secs,
483 "last_seen_secs": device.last_seen_secs,
484 "public_url": url,
485 })
486 })
487 .collect();
488
489 axum::Json(serde_json::json!({ "devices": devices })).into_response()
490}
491
492async fn data_handler(ws: WebSocketUpgrade, State(state): State<RelayState>) -> Response {
499 ws.on_upgrade(move |socket| attach_data_connection(socket, state))
500}
501
502async fn attach_data_connection(mut socket: WebSocket, state: RelayState) {
504 let first = tokio::time::timeout(ENROLL_TIMEOUT, socket.recv()).await;
505 let Ok(Some(Ok(Message::Text(text)))) = first else {
506 let _ = socket.close().await;
507 return;
508 };
509
510 let Ok(DeviceMessage::Attach {
511 device_id,
512 enroll_token,
513 }) = serde_json::from_str::<DeviceMessage>(&text)
514 else {
515 let _ = socket.close().await;
516 return;
517 };
518
519 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
520 tracing::debug!(target: "relay", "data connection rejected: bad token");
521 let _ = socket.close().await;
522 return;
523 }
524
525 let Some(device) = state.devices.get(&device_id) else {
526 let _ = socket.close().await;
527 return;
528 };
529
530 if let Some(mut extra) = device.offer(socket).await {
533 let _ = extra.close().await;
534 }
535}
536
537async fn proxy_handler(State(state): State<RelayState>, request: Request) -> Response {
539 let path_and_query = request
540 .uri()
541 .path_and_query()
542 .map(|p| p.as_str().to_string())
543 .unwrap_or_else(|| request.uri().path().to_string());
544
545 let Some((device_id, tail)) = split_device_path(&path_and_query) else {
546 return StatusCode::NOT_FOUND.into_response();
547 };
548
549 let Some(device) = state.devices.get(device_id) else {
550 return (StatusCode::BAD_GATEWAY, "device is not connected").into_response();
553 };
554
555 let method = request.method().to_string();
556 let headers: Vec<(String, String)> = request
557 .headers()
558 .iter()
559 .filter(|(name, _)| is_forwardable(name.as_str()))
560 .filter_map(|(name, value)| {
561 value
562 .to_str()
563 .ok()
564 .map(|v| (name.as_str().to_string(), v.to_string()))
565 })
566 .collect();
567
568 if is_websocket_upgrade(request.headers()) {
574 let (mut parts, _) = request.into_parts();
575 let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
576 Ok(upgrade) => upgrade,
577 Err(rejection) => return rejection.into_response(),
578 };
579 let proxied = ProxyRequest {
580 method,
581 path: tail,
582 headers,
583 websocket: true,
584 };
585 return upgrade.on_upgrade(move |client| pipe_websocket(client, device, proxied));
586 }
587
588 let body = match axum::body::to_bytes(request.into_body(), MAX_BODY).await {
589 Ok(body) => body,
590 Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
591 };
592
593 let Some(conn) = device.take(POOL_WAIT).await else {
594 return (
597 StatusCode::SERVICE_UNAVAILABLE,
598 [("retry-after", "1")],
599 "no data connection available",
600 )
601 .into_response();
602 };
603
604 match tokio::time::timeout(
605 REQUEST_TIMEOUT,
606 forward(
607 conn,
608 ProxyRequest {
609 method,
610 path: tail,
611 headers,
612 websocket: false,
613 },
614 body,
615 ),
616 )
617 .await
618 {
619 Ok(Ok(response)) => response,
620 Ok(Err(reason)) => {
621 tracing::debug!(target: "relay", device_id = %device.id, reason, "proxy failed");
622 (StatusCode::BAD_GATEWAY, "device did not answer").into_response()
623 }
624 Err(_) => (StatusCode::GATEWAY_TIMEOUT, "device timed out").into_response(),
625 }
626}
627
628fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
630 let header_contains = |name: axum::http::HeaderName, needle: &str| {
631 headers
632 .get(name)
633 .and_then(|value| value.to_str().ok())
634 .is_some_and(|value| value.to_ascii_lowercase().contains(needle))
635 };
636 header_contains(axum::http::header::UPGRADE, "websocket")
637 && header_contains(axum::http::header::CONNECTION, "upgrade")
638}
639
640async fn pipe_websocket(mut client: WebSocket, device: Arc<Device>, request: ProxyRequest) {
646 let Some(mut conn) = device.take(POOL_WAIT).await else {
647 tracing::debug!(target: "relay", device_id = %device.id, "no data connection for websocket");
648 let _ = client.close().await;
649 return;
650 };
651
652 let Ok(header) = serde_json::to_string(&request) else {
653 let _ = client.close().await;
654 return;
655 };
656 if conn.send(Message::Text(header.into())).await.is_err() {
657 let _ = client.close().await;
658 return;
659 }
660
661 let switched = matches!(
664 conn.recv().await,
665 Some(Ok(Message::Text(ref text)))
666 if serde_json::from_str::<ProxyResponse>(text)
667 .map(|response| response.status == 101)
668 .unwrap_or(false)
669 );
670 if !switched {
671 let _ = client.close().await;
672 let _ = conn.close().await;
673 return;
674 }
675
676 loop {
679 tokio::select! {
680 from_client = client.recv() => {
681 match from_client {
682 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
683 Some(Ok(message)) => {
684 if conn.send(message).await.is_err() {
685 break;
686 }
687 }
688 }
689 }
690 from_device = conn.recv() => {
691 match from_device {
692 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
693 Some(Ok(message)) => {
694 if client.send(message).await.is_err() {
695 break;
696 }
697 }
698 }
699 }
700 }
701 }
702
703 let _ = client.close().await;
704 let _ = conn.close().await;
705}
706
707const MAX_BODY: usize = 8 * 1024 * 1024;
709
710async fn forward(
715 mut conn: WebSocket,
716 request: ProxyRequest,
717 body: Bytes,
718) -> Result<Response, &'static str> {
719 let header = serde_json::to_string(&request).map_err(|_| "request-encode")?;
720 conn.send(Message::Text(header.into()))
721 .await
722 .map_err(|_| "request-header-send")?;
723 conn.send(Message::Binary(body))
724 .await
725 .map_err(|_| "request-body-send")?;
726
727 let head: ProxyResponse = loop {
728 match conn.recv().await {
729 Some(Ok(Message::Text(text))) => {
730 break serde_json::from_str(&text).map_err(|_| "response-decode")?
731 }
732 Some(Ok(_)) => continue,
733 _ => return Err("response-header-missing"),
734 }
735 };
736
737 let mut body = Vec::new();
738 while let Some(Ok(message)) = conn.recv().await {
739 match message {
740 Message::Binary(chunk) => body.extend_from_slice(&chunk),
741 Message::Close(_) => break,
742 _ => continue,
743 }
744 }
745
746 let mut response = Response::builder().status(head.status);
747 for (name, value) in head.headers {
748 if is_forwardable(&name) {
749 response = response.header(name, value);
750 }
751 }
752 response
753 .body(axum::body::Body::from(body))
754 .map_err(|_| "response-build")
755}
756
757async fn reject_and_close<S>(sink: &mut S, code: &str, message: &str)
759where
760 S: SinkExt<Message> + Unpin,
761{
762 let rejected = RelayMessage::Rejected {
763 code: code.to_string(),
764 message: message.to_string(),
765 };
766 let _ = send_json(sink, &rejected).await;
767 let _ = sink.close().await;
768}
769
770async fn send_json<S, T>(sink: &mut S, message: &T) -> Result<(), ()>
772where
773 S: SinkExt<Message> + Unpin,
774 T: serde::Serialize,
775{
776 let json = serde_json::to_string(message).map_err(|_| ())?;
777 sink.send(Message::Text(json.into())).await.map_err(|_| ())
778}
779
780fn constant_time_eq(a: &str, b: &str) -> bool {
786 let (a, b) = (a.as_bytes(), b.as_bytes());
787 if a.len() != b.len() {
788 return false;
789 }
790 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
791}
792
793#[cfg(test)]
794mod tests {
795 use super::*;
796
797 fn config() -> RelayConfig {
798 RelayConfig::new("127.0.0.1:0".parse().unwrap(), "secret")
799 }
800
801 #[test]
802 fn a_portless_base_on_a_nondefault_port_gets_a_corrected_suggestion() {
803 assert_eq!(
809 public_base_port_hint("https://labs.example.com", 8443).as_deref(),
810 Some("https://labs.example.com:8443")
811 );
812 assert_eq!(
813 public_base_port_hint("http://relay.local", 8080).as_deref(),
814 Some("http://relay.local:8080")
815 );
816 }
817
818 #[test]
819 fn a_base_matching_the_scheme_default_needs_no_hint() {
820 assert_eq!(public_base_port_hint("https://labs.example.com", 443), None);
821 assert_eq!(public_base_port_hint("http://relay.local", 80), None);
822 }
823
824 #[test]
825 fn an_explicit_port_is_the_operator_stating_intent() {
826 assert_eq!(
828 public_base_port_hint("https://labs.example.com:8443", 8443),
829 None
830 );
831 assert_eq!(
832 public_base_port_hint("https://labs.example.com:9000", 8443),
833 None
834 );
835 assert_eq!(
836 public_base_port_hint("https://labs.example.com:443", 8443),
837 None
838 );
839 }
840
841 #[test]
842 fn the_port_is_spliced_into_the_authority_not_the_tail() {
843 assert_eq!(
845 public_base_port_hint("https://labs.example.com/relay", 8443).as_deref(),
846 Some("https://labs.example.com:8443/relay")
847 );
848 }
849
850 #[test]
851 fn ipv6_literals_look_for_the_port_after_the_bracket() {
852 assert_eq!(
853 public_base_port_hint("https://[::1]", 8443).as_deref(),
854 Some("https://[::1]:8443")
855 );
856 assert_eq!(public_base_port_hint("https://[::1]:8443", 8443), None);
857 }
858
859 #[test]
860 fn an_unrecognized_scheme_is_left_alone() {
861 assert_eq!(public_base_port_hint("ws://relay.local", 8443), None);
862 }
863
864 #[test]
865 fn public_url_uses_the_device_path_prefix() {
866 let config = config().with_public_base("https://relay.example.com/");
867 assert_eq!(
868 config.public_url_for("dev-1", None),
869 "https://relay.example.com/d/dev-1"
870 );
871 }
872
873 #[test]
874 fn public_base_defaults_to_the_bind_address() {
875 let config = RelayConfig::new("127.0.0.1:8443".parse().unwrap(), "secret");
876 assert_eq!(
877 config.public_url_for("d", None),
878 "http://127.0.0.1:8443/d/d"
879 );
880 }
881
882 #[test]
883 fn an_observed_address_is_used_when_the_operator_configured_none() {
884 let config = config();
885 assert_eq!(
886 config.public_url_for("dev-1", Some("https://relay.example.com".into())),
887 "https://relay.example.com/d/dev-1"
888 );
889 }
890
891 #[test]
892 fn a_configured_base_wins_over_what_the_connection_observed() {
893 let config = config().with_public_base("https://canonical.example");
894 assert_eq!(
895 config.public_url_for("dev-1", Some("https://whatever.invalid".into())),
896 "https://canonical.example/d/dev-1"
897 );
898 }
899
900 #[test]
901 fn the_forwarded_scheme_and_host_are_preferred_over_the_direct_host() {
902 let mut headers = HeaderMap::new();
903 headers.insert(axum::http::header::HOST, "127.0.0.1:8443".parse().unwrap());
904 assert_eq!(
905 observed_base(&headers, false).as_deref(),
906 Some("http://127.0.0.1:8443")
907 );
908
909 headers.insert("x-forwarded-proto", "https".parse().unwrap());
910 headers.insert("x-forwarded-host", "relay.example.com".parse().unwrap());
911 assert_eq!(
912 observed_base(&headers, false).as_deref(),
913 Some("https://relay.example.com")
914 );
915 }
916
917 #[test]
918 fn a_proxy_chain_scheme_takes_the_first_entry() {
919 let mut headers = HeaderMap::new();
920 headers.insert(
921 axum::http::header::HOST,
922 "relay.example.com".parse().unwrap(),
923 );
924 headers.insert("x-forwarded-proto", "https, http".parse().unwrap());
925 assert_eq!(
926 observed_base(&headers, false).as_deref(),
927 Some("https://relay.example.com")
928 );
929 }
930
931 #[test]
932 fn no_host_header_means_nothing_observed() {
933 assert!(observed_base(&HeaderMap::new(), false).is_none());
934 }
935
936 #[test]
937 fn terminating_tls_makes_the_advertised_url_https() {
938 let mut headers = HeaderMap::new();
941 headers.insert(
942 axum::http::header::HOST,
943 "relay.example.com".parse().unwrap(),
944 );
945 assert_eq!(
946 observed_base(&headers, true).as_deref(),
947 Some("https://relay.example.com")
948 );
949 }
950
951 #[test]
952 fn device_names_must_be_url_path_safe() {
953 assert!(is_valid_device_name("build-box"));
954 assert!(is_valid_device_name("laptop_2"));
955 assert!(is_valid_device_name("a"));
956
957 assert!(!is_valid_device_name(""));
958 assert!(!is_valid_device_name("has space"));
959 assert!(!is_valid_device_name("../escape"));
960 assert!(!is_valid_device_name("slash/inside"));
961 assert!(!is_valid_device_name("querylike?x=1"));
962 assert!(!is_valid_device_name(&"x".repeat(65)));
963 }
964
965 #[test]
966 fn constant_time_eq_matches_equality() {
967 assert!(constant_time_eq("abc", "abc"));
968 assert!(!constant_time_eq("abc", "abd"));
969 assert!(!constant_time_eq("abc", "ab"));
970 assert!(constant_time_eq("", ""));
971 }
972}