use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
use tokio_tungstenite::tungstenite::http::{HeaderName, HeaderValue};
use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::{MaybeTlsStream, WebSocketStream};
type WsStream = WebSocketStream<MaybeTlsStream<tokio::net::TcpStream>>;
use super::protocol::{DeviceMessage, RelayMessage, PROTOCOL_VERSION};
use super::proxy::{is_forwardable, ProxyRequest, ProxyResponse};
use crate::error::ShellTunnelError;
use crate::Result;
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(30);
const BACKOFF_MIN: Duration = Duration::from_secs(1);
const BACKOFF_MAX: Duration = Duration::from_secs(60);
#[derive(Debug, Clone)]
pub struct RelayClientConfig {
pub relay_url: String,
pub enroll_token: String,
pub local: SocketAddr,
pub label: Option<String>,
pub device_name: Option<String>,
pub fingerprint: Option<String>,
pub ca_file: Option<PathBuf>,
pub enrolled: Option<tokio::sync::mpsc::UnboundedSender<String>>,
}
impl RelayClientConfig {
pub fn control_url(&self) -> String {
format!("{}/relay/v1/control", self.base())
}
pub fn data_url(&self) -> String {
format!("{}/relay/v1/data", self.base())
}
pub fn dial_target(&self) -> String {
let base = self.base();
let (scheme, rest) = match base.split_once("://") {
Some((scheme, rest)) => (scheme.to_string(), rest.to_string()),
None => ("wss".to_string(), base.clone()),
};
let authority = rest.split('/').next().unwrap_or(&rest).to_string();
let has_port = match authority.rsplit_once(']') {
Some((_, tail)) => tail.starts_with(':'),
None => authority.contains(':'),
};
if has_port {
authority
} else {
let implied = if scheme == "wss" { 443 } else { 80 };
format!("{authority}:{implied}")
}
}
fn base(&self) -> String {
let trimmed = self.relay_url.trim_end_matches('/');
match trimmed.split_once("://") {
Some(("https", rest)) => format!("wss://{rest}"),
Some(("http", rest)) => format!("ws://{rest}"),
Some(_) => trimmed.to_string(),
None => format!("wss://{trimmed}"),
}
}
}
pub fn default_device_name() -> Option<String> {
#[cfg(windows)]
const HOST_VAR: &str = "COMPUTERNAME";
#[cfg(not(windows))]
const HOST_VAR: &str = "HOSTNAME";
let raw = std::env::var(HOST_VAR)
.ok()
.filter(|v| !v.trim().is_empty());
let raw = raw.or_else(|| {
let output = std::process::Command::new("hostname").output().ok()?;
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
(!name.is_empty()).then_some(name)
})?;
sanitize_device_name(&raw)
}
fn sanitize_device_name(raw: &str) -> Option<String> {
let short = raw.split('.').next().unwrap_or(raw);
let cleaned: String = short
.chars()
.filter(|c| c.is_ascii_alphanumeric() || *c == '-' || *c == '_')
.take(64)
.collect();
(!cleaned.is_empty()).then_some(cleaned)
}
fn install_crypto_provider() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
pub async fn run(config: RelayClientConfig) -> Result<()> {
install_crypto_provider();
let mut backoff = BACKOFF_MIN;
loop {
match attach(&config).await {
Ok(()) => {
tracing::warn!(target: "relay-client", "relay connection closed; reconnecting");
backoff = BACKOFF_MIN;
}
Err(e) => {
tracing::warn!(target: "relay-client", "relay connection failed: {e}");
}
}
tokio::time::sleep(backoff).await;
backoff = (backoff * 2).min(BACKOFF_MAX);
}
}
pub async fn attach(config: &RelayClientConfig) -> Result<()> {
install_crypto_provider();
let (mut control, _) = tokio_tungstenite::connect_async_tls_with_config(
config
.control_url()
.into_client_request()
.map_err(|e| ShellTunnelError::Tunnel(format!("bad relay url: {e}")))?,
None,
false,
connector(config)?,
)
.await
.map_err(|e| ShellTunnelError::Tunnel(explain_dial_failure(&e, config)))?;
let enroll = DeviceMessage::Enroll {
enroll_token: config.enroll_token.clone(),
version: PROTOCOL_VERSION,
label: config.label.clone(),
device_name: config.device_name.clone(),
};
send(&mut control, &enroll).await?;
let device_id = match recv(&mut control).await? {
RelayMessage::Enrolled {
device_id,
public_url,
} => {
if let Some(enrolled) = &config.enrolled {
let _ = enrolled.send(public_url.clone());
}
device_id
}
RelayMessage::Rejected { code, message } => {
return Err(ShellTunnelError::Tunnel(format!(
"relay refused this device ({code}): {message}"
)))
}
other => {
return Err(ShellTunnelError::Tunnel(format!(
"unexpected first message from relay: {other:?}"
)))
}
};
let mut heartbeat = tokio::time::interval(HEARTBEAT_INTERVAL);
heartbeat.tick().await;
loop {
tokio::select! {
incoming = control.next() => {
let Some(Ok(message)) = incoming else { return Ok(()) };
let Message::Text(text) = message else { continue };
match serde_json::from_str::<RelayMessage>(&text) {
Ok(RelayMessage::OpenData { count }) => {
for _ in 0..count {
spawn_data_connection(config.clone(), device_id.clone());
}
}
Ok(RelayMessage::HeartbeatAck) => {}
_ => continue,
}
}
_ = heartbeat.tick() => {
send(&mut control, &DeviceMessage::Heartbeat).await?;
}
}
}
}
fn spawn_data_connection(config: RelayClientConfig, device_id: String) {
tokio::spawn(async move {
if let Err(e) = serve_one(&config, &device_id).await {
tracing::debug!(target: "relay-client", "data connection ended: {e}");
}
});
}
async fn serve_one(config: &RelayClientConfig, device_id: &str) -> Result<()> {
let (mut conn, _) = tokio_tungstenite::connect_async_tls_with_config(
config
.data_url()
.into_client_request()
.map_err(|e| ShellTunnelError::Tunnel(format!("bad relay url: {e}")))?,
None,
false,
connector(config)?,
)
.await
.map_err(|e| ShellTunnelError::Tunnel(format!("data connection refused: {e}")))?;
let attach = DeviceMessage::Attach {
device_id: device_id.to_string(),
enroll_token: config.enroll_token.clone(),
};
send(&mut conn, &attach).await?;
let request: ProxyRequest = loop {
match conn.next().await {
Some(Ok(Message::Text(text))) => {
break serde_json::from_str(&text)
.map_err(|e| ShellTunnelError::Tunnel(format!("bad request header: {e}")))?
}
Some(Ok(_)) => continue,
_ => return Ok(()), }
};
if request.websocket {
return pipe_websocket(conn, config, &request).await;
}
let body = match conn.next().await {
Some(Ok(Message::Binary(bytes))) => bytes.to_vec(),
None | Some(Ok(Message::Close(_))) => Vec::new(),
Some(Err(e)) => {
return Err(ShellTunnelError::Tunnel(format!(
"could not read the request body: {e}"
)))
}
Some(Ok(_)) => Vec::new(),
};
let (status, headers, body) = replay_locally(config.local, &request, body).await;
let head = ProxyResponse { status, headers };
let json = serde_json::to_string(&head)
.map_err(|e| ShellTunnelError::Tunnel(format!("cannot encode response: {e}")))?;
let _ = conn.send(Message::Text(json)).await;
let _ = conn.send(Message::Binary(body)).await;
let _ = conn.close(None).await;
Ok(())
}
fn advise(message: &mut String, line: &str) {
message.push_str("\n ");
message.push_str(line);
}
fn proxy_env_set() -> Vec<&'static str> {
let mut found: Vec<&'static str> = Vec::new();
for name in [
"HTTPS_PROXY",
"https_proxy",
"HTTP_PROXY",
"http_proxy",
"ALL_PROXY",
"all_proxy",
] {
if !std::env::var_os(name).is_some_and(|value| !value.is_empty()) {
continue;
}
if found.iter().any(|seen| seen.eq_ignore_ascii_case(name)) {
continue;
}
found.push(name);
}
found
}
fn explain_dial_failure(
error: &tokio_tungstenite::tungstenite::Error,
config: &RelayClientConfig,
) -> String {
let text = error.to_string();
let mut message = format!("cannot reach relay: {text}");
let target = config.dial_target();
if text.contains("BadSignature") || text.contains("UnknownIssuer") {
let ca = config
.ca_file
.as_ref()
.map(|path| path.display().to_string())
.unwrap_or_else(|| "the system trust store".to_string());
advise(
&mut message,
&format!("{ca} does not vouch for the certificate this relay is presenting."),
);
advise(
&mut message,
"A relay that regenerated its certificate, or a copy taken from a different",
);
advise(
&mut message,
"relay directory, both look like this. Copy the relay's current",
);
advise(
&mut message,
"shell-tunnel-cert.pem and pass it as --relay-ca.",
);
} else if text.contains("NotValidForName") {
advise(
&mut message,
"The certificate does not cover the name being dialled.",
);
advise(
&mut message,
"Start the relay with --public-base for that name, after deleting",
);
advise(
&mut message,
"shell-tunnel-cert.pem and shell-tunnel-key.pem so it is regenerated.",
);
} else if let tokio_tungstenite::tungstenite::Error::Io(io) = error {
match io.kind() {
std::io::ErrorKind::TimedOut => {
advise(&mut message, &format!("Nothing answered at {target}."));
advise(
&mut message,
"The connection was not refused, it was swallowed — something between",
);
advise(
&mut message,
"this machine and that address is dropping it: a firewall, a route, or",
);
advise(&mut message, "an outbound policy.");
advise(
&mut message,
"No shell-tunnel flag changes this. The next thing to check is whether",
);
advise(
&mut message,
"this machine can open *any* outbound connection to that port; if the",
);
advise(
&mut message,
"relay can be moved to a port the network already allows out, try that.",
);
}
std::io::ErrorKind::ConnectionRefused => {
advise(
&mut message,
&format!("{target} was reached, and nothing is listening on it."),
);
advise(
&mut message,
"The address and the route to it are fine, so this is the relay's end:",
);
advise(
&mut message,
"check that it is running, and that it bound the port being dialled.",
);
}
_ => advise(&mut message, &format!("Dialling {target}.")),
}
} else {
advise(&mut message, &format!("Dialling {target}."));
}
let proxies = proxy_env_set();
if !proxies.is_empty() {
let (verb, pronoun) = if proxies.len() == 1 {
("is", "it")
} else {
("are", "them")
};
advise(
&mut message,
&format!(
"{} {verb} set, and this client does not use {pronoun}:",
proxies.join(", ")
),
);
advise(
&mut message,
&format!("the relay at {target} is dialled directly. On a network that requires"),
);
advise(
&mut message,
"a proxy for outbound connections, that alone explains this.",
);
}
message
}
#[derive(Debug)]
struct PinnedCertificate {
expected: Vec<u8>,
provider: std::sync::Arc<rustls::crypto::CryptoProvider>,
}
impl rustls::client::danger::ServerCertVerifier for PinnedCertificate {
fn verify_server_cert(
&self,
end_entity: &rustls::pki_types::CertificateDer<'_>,
_intermediates: &[rustls::pki_types::CertificateDer<'_>],
_server_name: &rustls::pki_types::ServerName<'_>,
_ocsp_response: &[u8],
_now: rustls::pki_types::UnixTime,
) -> std::result::Result<rustls::client::danger::ServerCertVerified, rustls::Error> {
let presented = ring::digest::digest(&ring::digest::SHA256, end_entity.as_ref());
if presented.as_ref() == self.expected.as_slice() {
Ok(rustls::client::danger::ServerCertVerified::assertion())
} else {
Err(rustls::Error::InvalidCertificate(
rustls::CertificateError::ApplicationVerificationFailure,
))
}
}
fn verify_tls12_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls12_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn verify_tls13_signature(
&self,
message: &[u8],
cert: &rustls::pki_types::CertificateDer<'_>,
dss: &rustls::DigitallySignedStruct,
) -> std::result::Result<rustls::client::danger::HandshakeSignatureValid, rustls::Error> {
rustls::crypto::verify_tls13_signature(
message,
cert,
dss,
&self.provider.signature_verification_algorithms,
)
}
fn supported_verify_schemes(&self) -> Vec<rustls::SignatureScheme> {
self.provider
.signature_verification_algorithms
.supported_schemes()
}
}
fn connector(config: &RelayClientConfig) -> Result<Option<tokio_tungstenite::Connector>> {
if let Some(fingerprint) = &config.fingerprint {
let expected = crate::fingerprint::parse(fingerprint)
.map_err(|e| ShellTunnelError::Tunnel(format!("bad --relay-fingerprint: {e}")))?;
let provider = rustls::crypto::CryptoProvider::get_default()
.cloned()
.unwrap_or_else(|| std::sync::Arc::new(rustls::crypto::ring::default_provider()));
let tls = rustls::ClientConfig::builder()
.dangerous()
.with_custom_certificate_verifier(std::sync::Arc::new(PinnedCertificate {
expected,
provider,
}))
.with_no_client_auth();
return Ok(Some(tokio_tungstenite::Connector::Rustls(
std::sync::Arc::new(tls),
)));
}
let Some(path) = &config.ca_file else {
return Ok(None);
};
let pem = std::fs::read(path)
.map_err(|e| ShellTunnelError::Tunnel(format!("cannot read CA {}: {e}", path.display())))?;
let mut roots = rustls::RootCertStore::empty();
let mut added = 0usize;
for cert in rustls_pemfile_certs(&pem) {
if roots.add(cert).is_ok() {
added += 1;
}
}
if added == 0 {
return Err(ShellTunnelError::Tunnel(format!(
"{} contains no usable certificate authority",
path.display()
)));
}
roots.extend(webpki_roots::TLS_SERVER_ROOTS.iter().cloned());
let tls = rustls::ClientConfig::builder()
.with_root_certificates(roots)
.with_no_client_auth();
Ok(Some(tokio_tungstenite::Connector::Rustls(
std::sync::Arc::new(tls),
)))
}
fn rustls_pemfile_certs(pem: &[u8]) -> Vec<rustls::pki_types::CertificateDer<'static>> {
let mut cursor = pem;
rustls_pemfile::certs(&mut cursor)
.filter_map(|cert| cert.ok())
.collect()
}
async fn pipe_websocket(
mut conn: WsStream,
config: &RelayClientConfig,
request: &ProxyRequest,
) -> Result<()> {
let local_url = format!("ws://{}{}", config.local, request.path);
let mut builder = local_url
.into_client_request()
.map_err(|e| ShellTunnelError::Tunnel(format!("bad local websocket url: {e}")))?;
for (name, value) in &request.headers {
if !is_forwardable(name) || name.eq_ignore_ascii_case("sec-websocket-key") {
continue;
}
if let (Ok(name), Ok(value)) = (
HeaderName::from_bytes(name.as_bytes()),
HeaderValue::from_str(value),
) {
builder.headers_mut().insert(name, value);
}
}
let local = match tokio_tungstenite::connect_async(builder).await {
Ok((socket, _)) => socket,
Err(e) => {
tracing::debug!(target: "relay-client", "local websocket refused: {e}");
let head = ProxyResponse {
status: 502,
headers: Vec::new(),
};
if let Ok(json) = serde_json::to_string(&head) {
let _ = conn.send(Message::Text(json)).await;
}
let _ = conn.close(None).await;
return Ok(());
}
};
let head = ProxyResponse {
status: 101,
headers: Vec::new(),
};
let json = serde_json::to_string(&head)
.map_err(|e| ShellTunnelError::Tunnel(format!("cannot encode response: {e}")))?;
conn.send(Message::Text(json))
.await
.map_err(|_| ShellTunnelError::Tunnel("relay connection lost".to_string()))?;
let (mut local_tx, mut local_rx) = local.split();
let (mut relay_tx, mut relay_rx) = conn.split();
loop {
tokio::select! {
from_relay = relay_rx.next() => {
match from_relay {
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(message)) => {
if local_tx.send(message).await.is_err() {
break;
}
}
}
}
from_local = local_rx.next() => {
match from_local {
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
Some(Ok(message)) => {
if relay_tx.send(message).await.is_err() {
break;
}
}
}
}
}
}
let _ = local_tx.close().await;
let _ = relay_tx.close().await;
Ok(())
}
async fn replay_locally(
local: SocketAddr,
request: &ProxyRequest,
body: Vec<u8>,
) -> (u16, Vec<(String, String)>, Vec<u8>) {
use tokio::io::{AsyncReadExt, AsyncWriteExt};
let stream = match tokio::net::TcpStream::connect(local).await {
Ok(stream) => stream,
Err(e) => return bad_gateway(format!("local server unreachable: {e}")),
};
let mut head = format!(
"{} {} HTTP/1.1\r\nHost: {}\r\nConnection: close\r\ncontent-length: {}\r\n",
request.method,
request.path,
local,
body.len()
);
for (name, value) in &request.headers {
if is_forwardable(name) && !name.eq_ignore_ascii_case("content-length") {
head.push_str(&format!("{name}: {value}\r\n"));
}
}
head.push_str("\r\n");
let (mut read_half, mut write_half) = stream.into_split();
let answered = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
let reader_answered = std::sync::Arc::clone(&answered);
let reader = tokio::spawn(async move {
let mut raw = Vec::new();
let mut buf = vec![0u8; 16 * 1024];
let ok = loop {
match read_half.read(&mut buf).await {
Ok(0) => break true,
Ok(n) => {
reader_answered.store(true, std::sync::atomic::Ordering::Release);
raw.extend_from_slice(&buf[..n]);
}
Err(_) => break false,
}
};
(raw, ok)
});
let mut write_failed = write_half.write_all(head.as_bytes()).await.is_err();
if !write_failed {
for chunk in body.chunks(64 * 1024) {
tokio::task::yield_now().await;
if answered.load(std::sync::atomic::Ordering::Acquire) {
break;
}
if write_half.write_all(chunk).await.is_err() {
write_failed = true;
break;
}
}
}
let (raw, read_ok) = reader.await.unwrap_or_else(|_| (Vec::new(), false));
drop(write_half);
if !raw.is_empty() {
return parse_response(&raw);
}
if write_failed {
return bad_gateway("local server closed the connection".to_string());
}
if !read_ok {
return bad_gateway("local server response was cut short".to_string());
}
parse_response(&raw)
}
fn parse_response(raw: &[u8]) -> (u16, Vec<(String, String)>, Vec<u8>) {
let split = raw
.windows(4)
.position(|w| w == b"\r\n\r\n")
.map(|i| i + 4)
.unwrap_or(raw.len());
let (head, body) = raw.split_at(split);
let head = String::from_utf8_lossy(head);
let mut lines = head.lines();
let status = lines
.next()
.and_then(|line| line.split_whitespace().nth(1))
.and_then(|code| code.parse().ok())
.unwrap_or(502);
let headers = lines
.filter_map(|line| line.split_once(':'))
.map(|(name, value)| (name.trim().to_string(), value.trim().to_string()))
.filter(|(name, _)| is_forwardable(name))
.collect();
(status, headers, body.to_vec())
}
fn bad_gateway(reason: String) -> (u16, Vec<(String, String)>, Vec<u8>) {
tracing::debug!(target: "relay-client", "{reason}");
(
502,
vec![("content-type".to_string(), "text/plain".to_string())],
b"device could not reach its local server".to_vec(),
)
}
async fn send<S>(socket: &mut S, message: &DeviceMessage) -> Result<()>
where
S: SinkExt<Message> + Unpin,
{
let json = serde_json::to_string(message)
.map_err(|e| ShellTunnelError::Tunnel(format!("cannot encode message: {e}")))?;
socket
.send(Message::Text(json))
.await
.map_err(|_| ShellTunnelError::Tunnel("relay connection lost".to_string()))
}
async fn recv<S>(socket: &mut S) -> Result<RelayMessage>
where
S: StreamExt<Item = std::result::Result<Message, tokio_tungstenite::tungstenite::Error>>
+ Unpin,
{
loop {
match socket.next().await {
Some(Ok(Message::Text(text))) => {
return serde_json::from_str(&text)
.map_err(|e| ShellTunnelError::Tunnel(format!("bad relay message: {e}")))
}
Some(Ok(_)) => continue,
_ => {
return Err(ShellTunnelError::Tunnel(
"relay closed the connection".to_string(),
))
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn config(relay_url: &str) -> RelayClientConfig {
RelayClientConfig {
relay_url: relay_url.to_string(),
enroll_token: "secret".to_string(),
local: "127.0.0.1:3000".parse().unwrap(),
label: None,
device_name: None,
fingerprint: None,
ca_file: None,
enrolled: None,
}
}
#[test]
fn a_fingerprint_is_what_the_connector_uses_when_given() {
let config = RelayClientConfig {
fingerprint: Some(crate::fingerprint::of_certificate(b"whatever")),
ca_file: Some(PathBuf::from("unused.pem")),
..config("wss://relay.example.com")
};
assert!(matches!(connector(&config), Ok(Some(_))));
}
#[test]
fn a_malformed_fingerprint_is_refused_before_dialling() {
let config = RelayClientConfig {
fingerprint: Some("sha256:not-a-real-digest".to_string()),
..config("wss://relay.example.com")
};
let err = match connector(&config) {
Err(e) => e.to_string(),
Ok(_) => panic!("a malformed fingerprint must not produce a connector"),
};
assert!(err.contains("--relay-fingerprint"), "{err}");
}
#[test]
fn a_certificate_mismatch_says_what_to_do_about_it() {
use tokio_tungstenite::tungstenite::Error;
let config = RelayClientConfig {
ca_file: Some(PathBuf::from("copied-cert.pem")),
..config("wss://relay.example.com")
};
let error = Error::Io(std::io::Error::other(
"invalid peer certificate: BadSignature",
));
let message = explain_dial_failure(&error, &config);
assert!(message.contains("copied-cert.pem"), "{message}");
assert!(message.contains("--relay-ca"), "{message}");
}
#[test]
fn a_name_mismatch_points_at_public_base() {
use tokio_tungstenite::tungstenite::Error;
let error = Error::Io(std::io::Error::other(
"invalid peer certificate: NotValidForName",
));
let message = explain_dial_failure(&error, &config("wss://relay.example.com"));
assert!(message.contains("--public-base"), "{message}");
}
#[test]
fn a_timeout_is_recognised_from_its_kind_not_its_language() {
use tokio_tungstenite::tungstenite::Error;
let localised = "연결된 구성원으로부터 응답이 없어 연결하지 못했거나, 호스트로부터 응답이 없어 연결이 끊어 졌습니다. (os error 10060)";
let error = Error::Io(std::io::Error::new(
std::io::ErrorKind::TimedOut,
localised.to_string(),
));
let message = explain_dial_failure(&error, &config("wss://relay.example.com:8443"));
assert!(message.contains("relay.example.com:8443"), "{message}");
assert!(message.contains("No shell-tunnel flag"), "{message}");
assert!(message.contains("os error 10060"), "{message}");
assert!(!message.contains("--relay-ca"), "{message}");
}
#[test]
#[cfg(windows)]
fn the_os_code_from_the_incident_reaches_the_timeout_branch() {
use tokio_tungstenite::tungstenite::Error;
let error = Error::Io(std::io::Error::from_raw_os_error(10060));
let message = explain_dial_failure(&error, &config("wss://relay.example.com:8443"));
assert!(message.contains("Nothing answered at"), "{message}");
}
#[test]
fn a_refusal_and_a_timeout_do_not_say_the_same_thing() {
use tokio_tungstenite::tungstenite::Error;
let refused = Error::Io(std::io::Error::from(std::io::ErrorKind::ConnectionRefused));
let refused = explain_dial_failure(&refused, &config("wss://relay.example.com:8443"));
let timed_out = Error::Io(std::io::Error::from(std::io::ErrorKind::TimedOut));
let timed_out = explain_dial_failure(&timed_out, &config("wss://relay.example.com:8443"));
assert!(refused.contains("nothing is listening"), "{refused}");
assert!(timed_out.contains("swallowed"), "{timed_out}");
assert_ne!(
refused, timed_out,
"the two must not collapse into one message"
);
}
#[test]
fn an_implied_port_is_spelled_out() {
assert_eq!(
config("wss://relay.example.com").dial_target(),
"relay.example.com:443"
);
assert_eq!(
config("http://relay.example.com").dial_target(),
"relay.example.com:80"
);
assert_eq!(
config("https://relay.example.com:8443/").dial_target(),
"relay.example.com:8443"
);
assert_eq!(config("wss://[::1]").dial_target(), "[::1]:443");
assert_eq!(config("wss://[::1]:9000").dial_target(), "[::1]:9000");
}
#[test]
fn an_unrelated_failure_is_left_alone() {
use tokio_tungstenite::tungstenite::Error;
let error = Error::Io(std::io::Error::other("connection refused"));
let message = explain_dial_failure(&error, &config("wss://relay.example.com"));
assert!(message.contains("connection refused"), "{message}");
assert!(!message.contains("--relay-ca"), "{message}");
}
#[test]
fn a_hostname_becomes_a_usable_routing_key() {
assert_eq!(
sanitize_device_name("UJ-Book3").as_deref(),
Some("UJ-Book3")
);
assert_eq!(
sanitize_device_name("build_box").as_deref(),
Some("build_box")
);
assert_eq!(
sanitize_device_name("box.example.com").as_deref(),
Some("box")
);
assert_eq!(sanitize_device_name("!!!").as_deref(), None);
assert_eq!(sanitize_device_name("").as_deref(), None);
assert_eq!(sanitize_device_name(&"x".repeat(100)).unwrap().len(), 64);
}
#[test]
fn this_machine_has_a_default_device_name() {
assert!(default_device_name().is_some());
}
#[test]
fn https_urls_become_websocket_urls() {
assert_eq!(
config("https://relay.example.com").control_url(),
"wss://relay.example.com/relay/v1/control"
);
assert_eq!(
config("http://127.0.0.1:8443").control_url(),
"ws://127.0.0.1:8443/relay/v1/control"
);
}
#[test]
fn websocket_urls_are_left_alone() {
assert_eq!(
config("wss://relay.example.com/").control_url(),
"wss://relay.example.com/relay/v1/control"
);
}
#[test]
fn a_bare_host_defaults_to_the_secure_scheme() {
assert_eq!(
config("relay.example.com").control_url(),
"wss://relay.example.com/relay/v1/control"
);
}
#[test]
fn data_urls_carry_no_credentials() {
let url = config("wss://relay.example.com").data_url();
assert_eq!(url, "wss://relay.example.com/relay/v1/data");
assert!(!url.contains("secret"), "{url}");
assert!(!url.contains('?'), "{url}");
}
#[test]
fn responses_are_split_into_status_headers_and_body() {
let raw = b"HTTP/1.1 201 Created\r\ncontent-type: application/json\r\nconnection: close\r\n\r\n{\"ok\":true}";
let (status, headers, body) = parse_response(raw);
assert_eq!(status, 201);
assert_eq!(body, b"{\"ok\":true}");
assert!(headers.contains(&("content-type".to_string(), "application/json".to_string())));
assert!(
!headers.iter().any(|(n, _)| n == "connection"),
"{headers:?}"
);
}
#[test]
fn a_malformed_response_is_reported_as_a_bad_gateway() {
let (status, _, _) = parse_response(b"garbage");
assert_eq!(status, 502);
}
}