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 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 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 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
144pub 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 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#[derive(Debug, Clone)]
182pub struct RelayState {
183 config: RelayConfig,
184 devices: DeviceRegistry,
185}
186
187impl RelayState {
188 pub fn new(config: RelayConfig) -> Self {
190 Self {
191 config,
192 devices: DeviceRegistry::new(),
193 }
194 }
195
196 pub fn devices(&self) -> &DeviceRegistry {
198 &self.devices
199 }
200}
201
202pub 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
223pub 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 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 let service = router.into_make_service_with_connect_info::<SocketAddr>();
252
253 #[cfg(feature = "tls")]
254 if let Some(files) = tls {
255 let config = crate::tls::acceptor(files.load()?);
258 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
274fn 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
287fn 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 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
311fn 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
323async 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
333async fn control_session(socket: WebSocket, state: RelayState, observed: Option<String>) {
335 let (mut sink, mut stream) = socket.split();
336
337 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 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 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 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 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 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 _ => 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
470async 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
507async fn data_handler(ws: WebSocketUpgrade, State(state): State<RelayState>) -> Response {
514 ws.on_upgrade(move |socket| attach_data_connection(socket, state))
515}
516
517async 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 if let Some(mut extra) = device.offer(socket).await {
548 let _ = extra.close().await;
549 }
550}
551
552async 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 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 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 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
643fn 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
655async 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 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 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
722const MAX_BODY: usize = 8 * 1024 * 1024;
724
725async 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
772async 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
785async 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
795fn 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 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 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 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 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 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 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 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 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}