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>,
}
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())
}
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,
} => {
println!("\nPublic URL: {public_url} (via relay)");
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(),
_ => 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 explain_dial_failure(
error: &tokio_tungstenite::tungstenite::Error,
config: &RelayClientConfig,
) -> String {
let text = error.to_string();
let mut message = format!("cannot reach relay: {text}");
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());
message.push_str(&format!(
"
{ca} does not vouch for the certificate this relay is presenting."
));
message.push_str(
"
A relay that regenerated its certificate, or a copy taken from a different",
);
message.push_str(
"
relay directory, both look like this. Copy the relay's current",
);
message.push_str(
"
shell-tunnel-cert.pem and pass it as --relay-ca.",
);
} else if text.contains("NotValidForName") {
message.push_str(
"
The certificate does not cover the name being dialled.",
);
message.push_str(
"
Start the relay with --public-base for that name, after deleting",
);
message.push_str(
"
shell-tunnel-cert.pem and shell-tunnel-key.pem so it is regenerated.",
);
}
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 mut 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");
if stream.write_all(head.as_bytes()).await.is_err() || stream.write_all(&body).await.is_err() {
return bad_gateway("local server closed the connection".to_string());
}
let mut raw = Vec::new();
if stream.read_to_end(&mut raw).await.is_err() {
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,
}
}
#[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 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);
}
}