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 ConnectInfo, FromRequestParts, Request, State,
30 },
31 http::{HeaderMap, StatusCode},
32 response::{IntoResponse, Response},
33 routing::{any, get},
34 Extension, Router,
35};
36use futures_util::{SinkExt, StreamExt};
37
38use crate::error::ShellTunnelError;
39use crate::security::{
40 generate_api_key, rate_limit_middleware, RateLimitCharge, RateLimitConfig, RateLimiter,
41};
42use protocol::{reject, DeviceMessage, RelayMessage, PROTOCOL_VERSION};
43use proxy::{
44 is_forwardable, split_device_path, ProxyRequest, ProxyResponse, POOL_WAIT, REQUEST_TIMEOUT,
45};
46use registry::{Device, DeviceRegistry};
47
48pub use registry::{DeviceRegistry as Registry, POOL_TARGET};
49
50pub const HEARTBEAT_TIMEOUT: Duration = Duration::from_secs(90);
52
53const ENROLL_TIMEOUT: Duration = Duration::from_secs(10);
55
56#[derive(Debug, Clone)]
58pub struct RelayConfig {
59 pub bind: SocketAddr,
61 pub enroll_token: String,
63 pub rate_limit: RateLimitConfig,
70 #[cfg(feature = "tls")]
72 pub tls: Option<crate::tls::TlsFiles>,
73 pub public_base: Option<String>,
79}
80
81impl RelayConfig {
82 pub fn new(bind: SocketAddr, enroll_token: impl Into<String>) -> Self {
84 Self {
85 bind,
86 enroll_token: enroll_token.into(),
87 rate_limit: RateLimitConfig::default(),
88 #[cfg(feature = "tls")]
89 tls: None,
90 public_base: None,
91 }
92 }
93
94 #[cfg(feature = "tls")]
96 pub fn with_tls(mut self, files: crate::tls::TlsFiles) -> Self {
97 self.tls = Some(files);
98 self
99 }
100
101 pub fn without_rate_limit(mut self) -> Self {
103 self.rate_limit.enabled = false;
104 self
105 }
106
107 pub fn with_public_base(mut self, base: impl Into<String>) -> Self {
109 self.public_base = Some(base.into().trim_end_matches('/').to_string());
110 self
111 }
112
113 pub fn resolved_public_base(&self) -> Option<String> {
124 self.public_base.as_deref().map(|base| {
125 public_base_port_hint(base, self.bind.port()).unwrap_or_else(|| base.to_string())
126 })
127 }
128
129 pub fn public_base_or(&self, observed: Option<String>) -> String {
135 self.resolved_public_base()
136 .or(observed)
137 .unwrap_or_else(|| format!("http://{}", self.bind))
138 }
139
140 pub fn public_url_for(&self, device_id: &str, observed: Option<String>) -> String {
142 format!("{}/d/{}", self.public_base_or(observed), device_id)
143 }
144}
145
146pub fn public_base_port_hint(base: &str, listen_port: u16) -> Option<String> {
156 let (scheme, rest) = base.split_once("://")?;
157 let default_port: u16 = match scheme {
158 "https" => 443,
159 "http" => 80,
160 _ => return None,
161 };
162 if listen_port == default_port {
163 return None;
164 }
165 let authority_end = rest.find('/').unwrap_or(rest.len());
166 let authority = &rest[..authority_end];
167 let has_port = match authority.rfind(']') {
170 Some(bracket) => authority[bracket..].contains(':'),
171 None => authority.contains(':'),
172 };
173 if has_port {
174 return None;
175 }
176 Some(format!(
177 "{scheme}://{authority}:{listen_port}{}",
178 &rest[authority_end..]
179 ))
180}
181
182#[derive(Debug, Clone)]
184pub struct RelayState {
185 config: RelayConfig,
186 devices: DeviceRegistry,
187 limiter: Arc<RateLimiter>,
194}
195
196impl RelayState {
197 pub fn new(config: RelayConfig) -> Self {
199 let limiter = Arc::new(RateLimiter::new(config.rate_limit.clone()));
200 Self {
201 config,
202 devices: DeviceRegistry::new(),
203 limiter,
204 }
205 }
206
207 pub fn devices(&self) -> &DeviceRegistry {
209 &self.devices
210 }
211}
212
213pub fn relay_router(state: RelayState) -> Router {
237 let limiter = Arc::clone(&state.limiter);
238
239 Router::new()
240 .route("/health", get(|| async { "OK" }))
241 .route("/relay/v1/control", get(control_handler))
242 .route("/relay/v1/data", get(data_handler))
243 .route("/relay/v1/devices", get(devices_handler))
244 .route("/d/{*rest}", any(proxy_handler))
245 .layer(axum::middleware::from_fn_with_state(
246 limiter,
247 rate_limit_middleware,
248 ))
249 .with_state(state)
250}
251
252pub async fn bind_relay(config: &RelayConfig) -> crate::Result<tokio::net::TcpListener> {
261 tokio::net::TcpListener::bind(config.bind)
262 .await
263 .map_err(crate::error::ShellTunnelError::Io)
264}
265
266pub async fn serve_relay(config: RelayConfig) -> crate::Result<()> {
272 let listener = bind_relay(&config).await?;
273 serve_relay_on(listener, config).await
274}
275
276pub async fn serve_relay_on(
278 listener: tokio::net::TcpListener,
279 config: RelayConfig,
280) -> crate::Result<()> {
281 let bind = config.bind;
282 #[cfg(feature = "tls")]
283 let tls = config.tls.clone();
284 let state = RelayState::new(config);
285 let router = relay_router(state.clone());
286
287 let sweeper = state.devices().clone();
290 tokio::spawn(async move {
291 let mut ticker = tokio::time::interval(HEARTBEAT_TIMEOUT / 3);
292 loop {
293 ticker.tick().await;
294 for id in sweeper.evict_stale(HEARTBEAT_TIMEOUT) {
295 tracing::info!(target: "relay", device_id = %id, "device evicted (no heartbeat)");
296 }
297 }
298 });
299
300 tracing::info!("relay listening on {}", bind);
303
304 let service = router.into_make_service_with_connect_info::<SocketAddr>();
307
308 #[cfg(feature = "tls")]
309 if let Some(files) = tls {
310 let config = crate::tls::acceptor(files.load()?);
313 crate::tls::watch(files, config.clone());
315 let std_listener = listener.into_std().map_err(ShellTunnelError::Io)?;
316 return axum_server::from_tcp_rustls(std_listener, config)
317 .map_err(ShellTunnelError::Io)?
318 .serve(service)
319 .await
320 .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())));
321 }
322
323 axum::serve(listener, service)
324 .await
325 .map_err(|e| ShellTunnelError::Io(std::io::Error::other(e.to_string())))?;
326 Ok(())
327}
328
329fn is_valid_device_name(name: &str) -> bool {
335 !name.is_empty()
336 && name.len() <= 64
337 && name
338 .chars()
339 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
340}
341
342fn observed_base(headers: &HeaderMap, tls: bool) -> Option<String> {
348 let host = headers
349 .get("x-forwarded-host")
350 .or_else(|| headers.get(axum::http::header::HOST))
351 .and_then(|value| value.to_str().ok())?;
352 if host.is_empty() {
353 return None;
354 }
355 let scheme = headers
359 .get("x-forwarded-proto")
360 .and_then(|value| value.to_str().ok())
361 .map(|proto| proto.split(',').next().unwrap_or(proto).trim().to_string())
362 .unwrap_or_else(|| if tls { "https" } else { "http" }.to_string());
363 Some(format!("{scheme}://{host}"))
364}
365
366fn serves_tls(_state: &RelayState) -> bool {
368 #[cfg(feature = "tls")]
369 {
370 _state.config.tls.is_some()
371 }
372 #[cfg(not(feature = "tls"))]
373 {
374 false
375 }
376}
377
378async fn control_handler(
380 ws: WebSocketUpgrade,
381 State(state): State<RelayState>,
382 ConnectInfo(peer): ConnectInfo<SocketAddr>,
383 charge: Option<Extension<RateLimitCharge>>,
384 headers: HeaderMap,
385) -> impl IntoResponse {
386 let observed = observed_base(&headers, serves_tls(&state));
387 let charge = charge.map(|Extension(charge)| charge);
388 ws.on_upgrade(move |socket| control_session(socket, state, observed, peer, charge))
389}
390
391async fn control_session(
393 socket: WebSocket,
394 state: RelayState,
395 observed: Option<String>,
396 peer: SocketAddr,
397 charge: Option<RateLimitCharge>,
398) {
399 let (mut sink, mut stream) = socket.split();
400
401 let first = match tokio::time::timeout(ENROLL_TIMEOUT, stream.next()).await {
404 Ok(Some(Ok(Message::Text(text)))) => text,
405 _ => return,
406 };
407
408 let enroll = match serde_json::from_str::<DeviceMessage>(&first) {
409 Ok(DeviceMessage::Enroll {
410 enroll_token,
411 version,
412 label,
413 device_name,
414 }) => (enroll_token, version, label, device_name),
415 _ => {
416 reject_and_close(
417 &mut sink,
418 reject::BAD_HANDSHAKE,
419 "expected an enroll message",
420 )
421 .await;
422 return;
423 }
424 };
425 let (enroll_token, version, label, device_name) = enroll;
426
427 if version != PROTOCOL_VERSION {
428 reject_and_close(
429 &mut sink,
430 reject::UNSUPPORTED_VERSION,
431 &format!("relay speaks protocol version {PROTOCOL_VERSION}"),
432 )
433 .await;
434 return;
435 }
436
437 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
438 tracing::debug!(target: "relay", "enrollment rejected: bad token");
442 reject_and_close(&mut sink, reject::BAD_TOKEN, "enrollment refused").await;
443 return;
444 }
445
446 if let Some(charge) = charge {
448 state.limiter.refund(peer.ip(), charge);
449 }
450
451 let device_id = match device_name {
456 Some(name) if !is_valid_device_name(&name) => {
457 reject_and_close(
458 &mut sink,
459 reject::BAD_DEVICE_NAME,
460 "device names may use letters, digits, '-' and '_' (1-64 characters)",
461 )
462 .await;
463 return;
464 }
465 Some(name) => name,
471 None => generate_api_key(),
472 };
473 let public_url = state.config.public_url_for(&device_id, observed);
474 let registry::DeviceHandles {
475 device,
476 mut refill_rx,
477 } = state.devices.attach(&device_id, label.clone());
478 tracing::info!(
479 target: "relay",
480 device_id = %device_id,
481 label = label.as_deref().unwrap_or("-"),
482 "device attached"
483 );
484
485 let enrolled = RelayMessage::Enrolled {
486 device_id: device_id.clone(),
487 public_url,
488 };
489 if send_json(&mut sink, &enrolled).await.is_err() {
490 state.devices.detach(&device_id);
491 return;
492 }
493
494 let fill = RelayMessage::OpenData {
496 count: registry::POOL_TARGET,
497 };
498 if send_json(&mut sink, &fill).await.is_err() {
499 state.devices.detach(&device_id);
500 return;
501 }
502
503 loop {
506 tokio::select! {
507 incoming = stream.next() => {
508 let Some(Ok(message)) = incoming else { break };
509 match message {
510 Message::Text(text) => match serde_json::from_str::<DeviceMessage>(&text) {
511 Ok(DeviceMessage::Heartbeat) => {
512 device.touch();
513 if send_json(&mut sink, &RelayMessage::HeartbeatAck).await.is_err() {
514 break;
515 }
516 }
517 _ => continue,
521 },
522 Message::Close(_) => break,
523 _ => continue,
524 }
525 }
526 refill = refill_rx.recv() => {
527 if refill.is_none() {
528 break;
529 }
530 if send_json(&mut sink, &RelayMessage::OpenData { count: 1 }).await.is_err() {
531 break;
532 }
533 }
534 }
535 }
536
537 state.devices.detach(&device_id);
538 tracing::info!(target: "relay", device_id = %device_id, "device detached");
539}
540
541async fn devices_handler(State(state): State<RelayState>, headers: HeaderMap) -> Response {
547 let presented = headers
548 .get(axum::http::header::AUTHORIZATION)
549 .and_then(|value| value.to_str().ok())
550 .and_then(|value| value.strip_prefix("Bearer "))
551 .unwrap_or("");
552 if !constant_time_eq(presented, &state.config.enroll_token) {
553 return StatusCode::UNAUTHORIZED.into_response();
554 }
555
556 let base = state
557 .config
558 .public_base_or(observed_base(&headers, serves_tls(&state)));
559 let devices: Vec<_> = state
560 .devices
561 .list()
562 .into_iter()
563 .map(|device| {
564 let url = format!("{}/d/{}", base, device.id);
565 let mut entry = serde_json::to_value(&device)
569 .unwrap_or_else(|_| serde_json::json!({ "id": device.id, "label": device.label }));
570 if let Some(object) = entry.as_object_mut() {
571 object.insert("public_url".to_string(), serde_json::Value::String(url));
572 }
573 entry
574 })
575 .collect();
576
577 axum::Json(serde_json::json!({ "devices": devices })).into_response()
578}
579
580async fn data_handler(
587 ws: WebSocketUpgrade,
588 State(state): State<RelayState>,
589 ConnectInfo(peer): ConnectInfo<SocketAddr>,
590 charge: Option<Extension<RateLimitCharge>>,
591) -> Response {
592 let charge = charge.map(|Extension(charge)| charge);
593 ws.on_upgrade(move |socket| attach_data_connection(socket, state, peer, charge))
594}
595
596async fn attach_data_connection(
598 mut socket: WebSocket,
599 state: RelayState,
600 peer: SocketAddr,
601 charge: Option<RateLimitCharge>,
602) {
603 let first = tokio::time::timeout(ENROLL_TIMEOUT, socket.recv()).await;
604 let Ok(Some(Ok(Message::Text(text)))) = first else {
605 let _ = socket.close().await;
606 return;
607 };
608
609 let Ok(DeviceMessage::Attach {
610 device_id,
611 enroll_token,
612 }) = serde_json::from_str::<DeviceMessage>(&text)
613 else {
614 let _ = socket.close().await;
615 return;
616 };
617
618 if !constant_time_eq(&enroll_token, &state.config.enroll_token) {
619 tracing::debug!(target: "relay", "data connection rejected: bad token");
620 let _ = socket.close().await;
621 return;
622 }
623
624 if let Some(charge) = charge {
629 state.limiter.refund(peer.ip(), charge);
630 }
631
632 let Some(device) = state.devices.get(&device_id) else {
633 let _ = socket.close().await;
634 return;
635 };
636
637 if let Some(mut extra) = device.offer(socket).await {
640 let _ = extra.close().await;
641 }
642}
643
644async fn proxy_handler(State(state): State<RelayState>, request: Request) -> Response {
646 let path_and_query = request
647 .uri()
648 .path_and_query()
649 .map(|p| p.as_str().to_string())
650 .unwrap_or_else(|| request.uri().path().to_string());
651
652 let Some((device_id, tail)) = split_device_path(&path_and_query) else {
653 return StatusCode::NOT_FOUND.into_response();
654 };
655
656 let Some(device) = state.devices.get(device_id) else {
657 return (StatusCode::BAD_GATEWAY, "device is not connected").into_response();
660 };
661
662 let method = request.method().to_string();
663 let headers: Vec<(String, String)> = request
664 .headers()
665 .iter()
666 .filter(|(name, _)| is_forwardable(name.as_str()))
667 .filter_map(|(name, value)| {
668 value
669 .to_str()
670 .ok()
671 .map(|v| (name.as_str().to_string(), v.to_string()))
672 })
673 .collect();
674
675 if is_websocket_upgrade(request.headers()) {
681 let (mut parts, _) = request.into_parts();
682 let upgrade = match WebSocketUpgrade::from_request_parts(&mut parts, &state).await {
683 Ok(upgrade) => upgrade,
684 Err(rejection) => return rejection.into_response(),
685 };
686 let proxied = ProxyRequest {
687 method,
688 path: tail,
689 headers,
690 websocket: true,
691 };
692 return upgrade.on_upgrade(move |client| pipe_websocket(client, device, proxied));
693 }
694
695 let body = match axum::body::to_bytes(request.into_body(), MAX_BODY).await {
696 Ok(body) => body,
697 Err(_) => return StatusCode::PAYLOAD_TOO_LARGE.into_response(),
698 };
699
700 let Some(conn) = device.take(POOL_WAIT).await else {
701 return (
704 StatusCode::SERVICE_UNAVAILABLE,
705 [("retry-after", "1")],
706 "no data connection available",
707 )
708 .into_response();
709 };
710
711 let started = std::time::Instant::now();
717 let outcome = tokio::time::timeout(
718 REQUEST_TIMEOUT,
719 forward(
720 conn,
721 ProxyRequest {
722 method,
723 path: tail,
724 headers,
725 websocket: false,
726 },
727 body,
728 ),
729 )
730 .await;
731 device.record_exchange(started.elapsed());
732
733 match outcome {
734 Ok(Ok(response)) => response,
735 Ok(Err(reason)) => {
736 tracing::debug!(target: "relay", device_id = %device.id, reason, "proxy failed");
737 (StatusCode::BAD_GATEWAY, "device did not answer").into_response()
738 }
739 Err(_) => (StatusCode::GATEWAY_TIMEOUT, "device timed out").into_response(),
740 }
741}
742
743fn is_websocket_upgrade(headers: &HeaderMap) -> bool {
745 let header_contains = |name: axum::http::HeaderName, needle: &str| {
746 headers
747 .get(name)
748 .and_then(|value| value.to_str().ok())
749 .is_some_and(|value| value.to_ascii_lowercase().contains(needle))
750 };
751 header_contains(axum::http::header::UPGRADE, "websocket")
752 && header_contains(axum::http::header::CONNECTION, "upgrade")
753}
754
755async fn pipe_websocket(mut client: WebSocket, device: Arc<Device>, request: ProxyRequest) {
761 let Some(mut conn) = device.take(POOL_WAIT).await else {
762 tracing::debug!(target: "relay", device_id = %device.id, "no data connection for websocket");
763 let _ = client.close().await;
764 return;
765 };
766
767 let Ok(header) = serde_json::to_string(&request) else {
768 let _ = client.close().await;
769 return;
770 };
771 if conn.send(Message::Text(header.into())).await.is_err() {
772 let _ = client.close().await;
773 return;
774 }
775
776 let switched = matches!(
779 conn.recv().await,
780 Some(Ok(Message::Text(ref text)))
781 if serde_json::from_str::<ProxyResponse>(text)
782 .map(|response| response.status == 101)
783 .unwrap_or(false)
784 );
785 if !switched {
786 let _ = client.close().await;
787 let _ = conn.close().await;
788 return;
789 }
790
791 loop {
794 tokio::select! {
795 from_client = client.recv() => {
796 match from_client {
797 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
798 Some(Ok(message)) => {
799 if conn.send(message).await.is_err() {
800 break;
801 }
802 }
803 }
804 }
805 from_device = conn.recv() => {
806 match from_device {
807 Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
808 Some(Ok(message)) => {
809 if client.send(message).await.is_err() {
810 break;
811 }
812 }
813 }
814 }
815 }
816 }
817
818 let _ = client.close().await;
819 let _ = conn.close().await;
820}
821
822const MAX_BODY: usize = 8 * 1024 * 1024;
824
825async fn forward(
830 mut conn: WebSocket,
831 request: ProxyRequest,
832 body: Bytes,
833) -> Result<Response, &'static str> {
834 let header = serde_json::to_string(&request).map_err(|_| "request-encode")?;
835 conn.send(Message::Text(header.into()))
836 .await
837 .map_err(|_| "request-header-send")?;
838 conn.send(Message::Binary(body))
839 .await
840 .map_err(|_| "request-body-send")?;
841
842 let head: ProxyResponse = loop {
843 match conn.recv().await {
844 Some(Ok(Message::Text(text))) => {
845 break serde_json::from_str(&text).map_err(|_| "response-decode")?
846 }
847 Some(Ok(_)) => continue,
848 _ => return Err("response-header-missing"),
849 }
850 };
851
852 let mut body = Vec::new();
861 loop {
862 match conn.recv().await {
863 Some(Ok(Message::Binary(chunk))) => body.extend_from_slice(&chunk),
864 Some(Ok(Message::Close(_))) | None => break,
865 Some(Ok(_)) => continue,
866 Some(Err(_)) => return Err("response-body-truncated"),
867 }
868 }
869
870 let mut response = Response::builder().status(head.status);
871 for (name, value) in head.headers {
872 if is_forwardable(&name) {
873 response = response.header(name, value);
874 }
875 }
876 response
877 .body(axum::body::Body::from(body))
878 .map_err(|_| "response-build")
879}
880
881async fn reject_and_close<S>(sink: &mut S, code: &str, message: &str)
883where
884 S: SinkExt<Message> + Unpin,
885{
886 let rejected = RelayMessage::Rejected {
887 code: code.to_string(),
888 message: message.to_string(),
889 };
890 let _ = send_json(sink, &rejected).await;
891 let _ = sink.close().await;
892}
893
894async fn send_json<S, T>(sink: &mut S, message: &T) -> Result<(), ()>
896where
897 S: SinkExt<Message> + Unpin,
898 T: serde::Serialize,
899{
900 let json = serde_json::to_string(message).map_err(|_| ())?;
901 sink.send(Message::Text(json.into())).await.map_err(|_| ())
902}
903
904fn constant_time_eq(a: &str, b: &str) -> bool {
910 let (a, b) = (a.as_bytes(), b.as_bytes());
911 if a.len() != b.len() {
912 return false;
913 }
914 a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
915}
916
917#[cfg(test)]
918mod tests {
919 use super::*;
920
921 fn config() -> RelayConfig {
922 RelayConfig::new("127.0.0.1:0".parse().unwrap(), "secret")
923 }
924
925 #[test]
926 fn a_portless_base_on_a_nondefault_port_gets_a_corrected_suggestion() {
927 assert_eq!(
933 public_base_port_hint("https://labs.example.com", 8443).as_deref(),
934 Some("https://labs.example.com:8443")
935 );
936 assert_eq!(
937 public_base_port_hint("http://relay.local", 8080).as_deref(),
938 Some("http://relay.local:8080")
939 );
940 }
941
942 #[test]
943 fn a_base_matching_the_scheme_default_needs_no_hint() {
944 assert_eq!(public_base_port_hint("https://labs.example.com", 443), None);
945 assert_eq!(public_base_port_hint("http://relay.local", 80), None);
946 }
947
948 #[test]
949 fn an_explicit_port_is_the_operator_stating_intent() {
950 assert_eq!(
952 public_base_port_hint("https://labs.example.com:8443", 8443),
953 None
954 );
955 assert_eq!(
956 public_base_port_hint("https://labs.example.com:9000", 8443),
957 None
958 );
959 assert_eq!(
960 public_base_port_hint("https://labs.example.com:443", 8443),
961 None
962 );
963 }
964
965 #[test]
966 fn the_port_is_spliced_into_the_authority_not_the_tail() {
967 assert_eq!(
969 public_base_port_hint("https://labs.example.com/relay", 8443).as_deref(),
970 Some("https://labs.example.com:8443/relay")
971 );
972 }
973
974 #[test]
975 fn ipv6_literals_look_for_the_port_after_the_bracket() {
976 assert_eq!(
977 public_base_port_hint("https://[::1]", 8443).as_deref(),
978 Some("https://[::1]:8443")
979 );
980 assert_eq!(public_base_port_hint("https://[::1]:8443", 8443), None);
981 }
982
983 #[test]
984 fn an_unrecognized_scheme_is_left_alone() {
985 assert_eq!(public_base_port_hint("ws://relay.local", 8443), None);
986 }
987
988 #[test]
989 fn public_url_uses_the_device_path_prefix() {
990 let config = RelayConfig::new("127.0.0.1:443".parse().unwrap(), "secret")
993 .with_public_base("https://relay.example.com/");
994 assert_eq!(
995 config.public_url_for("dev-1", None),
996 "https://relay.example.com/d/dev-1"
997 );
998 }
999
1000 #[test]
1001 fn a_portless_base_inherits_the_listen_port() {
1002 let config = RelayConfig::new("0.0.0.0:8443".parse().unwrap(), "secret")
1006 .with_public_base("https://labs.example.com");
1007 assert_eq!(
1008 config.resolved_public_base().as_deref(),
1009 Some("https://labs.example.com:8443")
1010 );
1011 assert_eq!(
1012 config.public_url_for("dev-1", None),
1013 "https://labs.example.com:8443/d/dev-1"
1014 );
1015 }
1016
1017 #[test]
1018 fn an_explicit_port_survives_resolution() {
1019 let config = RelayConfig::new("0.0.0.0:8443".parse().unwrap(), "secret")
1022 .with_public_base("https://labs.example.com:443");
1023 assert_eq!(
1024 config.resolved_public_base().as_deref(),
1025 Some("https://labs.example.com:443")
1026 );
1027 }
1028
1029 #[test]
1030 fn resolution_leaves_a_default_port_base_alone() {
1031 let config = RelayConfig::new("0.0.0.0:443".parse().unwrap(), "secret")
1033 .with_public_base("https://labs.example.com");
1034 assert_eq!(
1035 config.resolved_public_base().as_deref(),
1036 Some("https://labs.example.com")
1037 );
1038 }
1039
1040 #[test]
1041 fn public_base_defaults_to_the_bind_address() {
1042 let config = RelayConfig::new("127.0.0.1:8443".parse().unwrap(), "secret");
1043 assert_eq!(
1044 config.public_url_for("d", None),
1045 "http://127.0.0.1:8443/d/d"
1046 );
1047 }
1048
1049 #[test]
1050 fn an_observed_address_is_used_when_the_operator_configured_none() {
1051 let config = config();
1052 assert_eq!(
1053 config.public_url_for("dev-1", Some("https://relay.example.com".into())),
1054 "https://relay.example.com/d/dev-1"
1055 );
1056 }
1057
1058 #[test]
1059 fn a_configured_base_wins_over_what_the_connection_observed() {
1060 let config = RelayConfig::new("127.0.0.1:443".parse().unwrap(), "secret")
1061 .with_public_base("https://canonical.example");
1062 assert_eq!(
1063 config.public_url_for("dev-1", Some("https://whatever.invalid".into())),
1064 "https://canonical.example/d/dev-1"
1065 );
1066 }
1067
1068 #[test]
1069 fn the_forwarded_scheme_and_host_are_preferred_over_the_direct_host() {
1070 let mut headers = HeaderMap::new();
1071 headers.insert(axum::http::header::HOST, "127.0.0.1:8443".parse().unwrap());
1072 assert_eq!(
1073 observed_base(&headers, false).as_deref(),
1074 Some("http://127.0.0.1:8443")
1075 );
1076
1077 headers.insert("x-forwarded-proto", "https".parse().unwrap());
1078 headers.insert("x-forwarded-host", "relay.example.com".parse().unwrap());
1079 assert_eq!(
1080 observed_base(&headers, false).as_deref(),
1081 Some("https://relay.example.com")
1082 );
1083 }
1084
1085 #[test]
1086 fn a_proxy_chain_scheme_takes_the_first_entry() {
1087 let mut headers = HeaderMap::new();
1088 headers.insert(
1089 axum::http::header::HOST,
1090 "relay.example.com".parse().unwrap(),
1091 );
1092 headers.insert("x-forwarded-proto", "https, http".parse().unwrap());
1093 assert_eq!(
1094 observed_base(&headers, false).as_deref(),
1095 Some("https://relay.example.com")
1096 );
1097 }
1098
1099 #[test]
1100 fn no_host_header_means_nothing_observed() {
1101 assert!(observed_base(&HeaderMap::new(), false).is_none());
1102 }
1103
1104 #[test]
1105 fn terminating_tls_makes_the_advertised_url_https() {
1106 let mut headers = HeaderMap::new();
1109 headers.insert(
1110 axum::http::header::HOST,
1111 "relay.example.com".parse().unwrap(),
1112 );
1113 assert_eq!(
1114 observed_base(&headers, true).as_deref(),
1115 Some("https://relay.example.com")
1116 );
1117 }
1118
1119 #[test]
1120 fn device_names_must_be_url_path_safe() {
1121 assert!(is_valid_device_name("build-box"));
1122 assert!(is_valid_device_name("laptop_2"));
1123 assert!(is_valid_device_name("a"));
1124
1125 assert!(!is_valid_device_name(""));
1126 assert!(!is_valid_device_name("has space"));
1127 assert!(!is_valid_device_name("../escape"));
1128 assert!(!is_valid_device_name("slash/inside"));
1129 assert!(!is_valid_device_name("querylike?x=1"));
1130 assert!(!is_valid_device_name(&"x".repeat(65)));
1131 }
1132
1133 #[test]
1134 fn constant_time_eq_matches_equality() {
1135 assert!(constant_time_eq("abc", "abc"));
1136 assert!(!constant_time_eq("abc", "abd"));
1137 assert!(!constant_time_eq("abc", "ab"));
1138 assert!(constant_time_eq("", ""));
1139 }
1140}