lesavka/client/src/relay_transport.rs

297 lines
11 KiB
Rust
Raw Normal View History

2026-04-30 08:16:57 -03:00
use anyhow::{Context, Result, bail};
use std::path::PathBuf;
use tonic::transport::{Certificate, Channel, ClientTlsConfig, Endpoint, Identity};
const DEFAULT_CLIENT_PKI_DIR: &str = ".config/lesavka/pki";
pub fn endpoint(server_addr: &str) -> Result<Endpoint> {
enforce_transport_policy(server_addr)?;
let mut endpoint =
Channel::from_shared(server_addr.to_string()).context("invalid relay server address")?;
if is_https(server_addr) {
endpoint = endpoint
.tls_config(client_tls_config(server_addr)?)
.context("configuring relay TLS")?;
}
Ok(endpoint)
}
pub async fn connect(server_addr: &str) -> Result<Channel> {
endpoint(server_addr)?
.tcp_nodelay(true)
.connect()
.await
.with_context(|| format!("connecting to relay at {server_addr}"))
}
pub fn enforce_transport_policy(server_addr: &str) -> Result<()> {
if is_https(server_addr) || is_local_http(server_addr) || allow_insecure_transport() {
return Ok(());
}
bail!(
"refusing insecure relay transport for {server_addr}; use https:// or set LESAVKA_ALLOW_INSECURE=1 for a deliberate lab override"
)
}
fn client_tls_config(server_addr: &str) -> Result<ClientTlsConfig> {
let mut tls = ClientTlsConfig::new()
.domain_name(tls_domain(server_addr))
.with_enabled_roots();
2026-04-30 11:38:16 -03:00
let ca_path = env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt"));
2026-04-30 08:16:57 -03:00
let cert_path = env_path("LESAVKA_TLS_CLIENT_CERT").or_else(|| default_pki_path("client.crt"));
let key_path = env_path("LESAVKA_TLS_CLIENT_KEY").or_else(|| default_pki_path("client.key"));
2026-04-30 11:38:16 -03:00
require_tls_enrollment(&ca_path, &cert_path, &key_path)?;
let ca_path = ca_path.expect("enrollment check guarantees CA path");
let cert_path = cert_path.expect("enrollment check guarantees cert path");
let key_path = key_path.expect("enrollment check guarantees key path");
let ca = std::fs::read(&ca_path)
.with_context(|| format!("reading TLS CA certificate {}", ca_path.display()))?;
tls = tls.ca_certificate(Certificate::from_pem(ca));
let cert = std::fs::read(&cert_path)
.with_context(|| format!("reading TLS client certificate {}", cert_path.display()))?;
let key = std::fs::read(&key_path)
.with_context(|| format!("reading TLS client key {}", key_path.display()))?;
tls = tls.identity(Identity::from_pem(cert, key));
2026-04-30 08:16:57 -03:00
Ok(tls)
}
2026-04-30 11:38:16 -03:00
fn require_tls_enrollment(
ca_path: &Option<PathBuf>,
cert_path: &Option<PathBuf>,
key_path: &Option<PathBuf>,
) -> Result<()> {
if path_exists(ca_path) && path_exists(cert_path) && path_exists(key_path) {
return Ok(());
}
let pki_hint = default_pki_path("")
.map(|path| path.display().to_string())
.unwrap_or_else(|| "~/.config/lesavka/pki".to_string());
bail!(
"TLS enrollment is missing for the Lesavka relay. Run the server installer first, then run the client installer with the server-generated bundle, or let the client installer auto-fetch it from the configured SSH host. Expected ca.crt, client.crt, and client.key under {pki_hint}."
)
}
fn path_exists(path: &Option<PathBuf>) -> bool {
path.as_ref().is_some_and(|path| path.exists())
}
2026-04-30 08:16:57 -03:00
fn tls_domain(server_addr: &str) -> String {
std::env::var("LESAVKA_TLS_DOMAIN")
.ok()
.filter(|value| !value.trim().is_empty())
.unwrap_or_else(|| {
host_from_uri(server_addr)
.unwrap_or("lesavka-server")
.to_string()
})
}
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn default_pki_path(file_name: &str) -> Option<PathBuf> {
let home = std::env::var_os("HOME")?;
Some(
PathBuf::from(home)
.join(DEFAULT_CLIENT_PKI_DIR)
.join(file_name),
)
}
fn allow_insecure_transport() -> bool {
std::env::var("LESAVKA_ALLOW_INSECURE")
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!value.is_empty() && value != "0" && value != "false" && value != "no"
})
.unwrap_or(false)
}
fn is_https(server_addr: &str) -> bool {
server_addr
.get(..8)
.is_some_and(|scheme| scheme.eq_ignore_ascii_case("https://"))
}
fn is_local_http(server_addr: &str) -> bool {
let Some(rest) = server_addr.strip_prefix("http://") else {
return false;
};
let host = rest
.split(['/', '?', '#'])
.next()
.unwrap_or_default()
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(rest);
let host = if host.starts_with('[') {
host.split(']')
.next()
.unwrap_or(host)
.trim_start_matches('[')
} else {
host.split(':').next().unwrap_or(host)
};
matches!(host, "localhost" | "::1" | "0.0.0.0") || host.starts_with("127.")
}
fn host_from_uri(server_addr: &str) -> Option<&str> {
let rest = server_addr.split_once("://")?.1;
let authority = rest.split(['/', '?', '#']).next().unwrap_or_default();
let authority = authority
.rsplit_once('@')
.map(|(_, host)| host)
.unwrap_or(authority);
if authority.starts_with('[') {
return authority
.split(']')
.next()
.map(|host| host.trim_start_matches('['))
.filter(|host| !host.is_empty());
}
authority.split(':').next().filter(|host| !host.is_empty())
}
#[cfg(test)]
mod tests {
use super::{endpoint, enforce_transport_policy, host_from_uri, is_local_http, tls_domain};
use temp_env::with_vars;
use tempfile::tempdir;
#[test]
fn localhost_http_is_allowed_for_tunnels() {
with_vars([("LESAVKA_ALLOW_INSECURE", None::<&str>)], || {
assert!(enforce_transport_policy("http://127.0.0.1:50051").is_ok());
assert!(enforce_transport_policy("http://localhost:50051").is_ok());
assert!(enforce_transport_policy("http://[::1]:50051").is_ok());
});
}
#[test]
fn public_http_requires_deliberate_override() {
with_vars([("LESAVKA_ALLOW_INSECURE", None::<&str>)], || {
let err = enforce_transport_policy("http://38.28.125.112:50051")
.expect_err("public http must be rejected");
assert!(
err.to_string()
.contains("refusing insecure relay transport")
);
});
with_vars([("LESAVKA_ALLOW_INSECURE", Some("1"))], || {
assert!(enforce_transport_policy("http://38.28.125.112:50051").is_ok());
});
}
#[test]
fn tls_domain_can_be_overridden_for_ip_backed_servers() {
with_vars([("LESAVKA_TLS_DOMAIN", None::<&str>)], || {
assert_eq!(tls_domain("https://38.28.125.112:50051"), "38.28.125.112");
});
with_vars([("LESAVKA_TLS_DOMAIN", Some("lesavka-server"))], || {
assert_eq!(tls_domain("https://38.28.125.112:50051"), "lesavka-server");
});
}
#[test]
fn endpoint_reports_invalid_local_uri_after_security_policy_passes() {
let err = endpoint("http://[::1").expect_err("malformed local URI should fail");
assert!(err.to_string().contains("invalid relay server address"));
}
#[test]
fn local_http_policy_handles_ipv4_ipv6_and_auth_shapes() {
assert!(is_local_http("http://user:pass@127.0.0.1:50051/path"));
assert!(is_local_http("http://[::1]:50051"));
assert!(is_local_http("http://0.0.0.0:50051"));
assert!(!is_local_http("https://127.0.0.1:50051"));
assert!(!is_local_http("http://38.28.125.112:50051"));
}
#[test]
fn host_parser_covers_ipv6_auth_and_missing_scheme() {
assert_eq!(
host_from_uri("https://user@lesavka-server:50051"),
Some("lesavka-server")
);
assert_eq!(host_from_uri("https://[::1]:50051"), Some("::1"));
assert_eq!(host_from_uri("lesavka-server:50051"), None);
}
#[test]
fn https_endpoint_reads_default_and_explicit_pki_paths_when_present() {
let dir = tempdir().expect("pki dir");
let pki = dir.path().join(".config/lesavka/pki");
std::fs::create_dir_all(&pki).expect("create pki");
std::fs::write(pki.join("ca.crt"), b"not a real ca").expect("ca");
std::fs::write(pki.join("client.crt"), b"not a real cert").expect("cert");
std::fs::write(pki.join("client.key"), b"not a real key").expect("key");
with_vars(
[
("HOME", Some(dir.path().to_string_lossy().as_ref())),
("LESAVKA_TLS_CA", None::<&str>),
("LESAVKA_TLS_CLIENT_CERT", None::<&str>),
("LESAVKA_TLS_CLIENT_KEY", None::<&str>),
],
|| {
let err = endpoint("https://lesavka-server:50051")
.expect_err("fake PEM should be rejected after file discovery");
assert!(err.to_string().contains("configuring relay TLS"));
},
);
with_vars(
[
("HOME", None::<&str>),
(
"LESAVKA_TLS_CA",
Some(pki.join("ca.crt").to_string_lossy().as_ref()),
),
(
"LESAVKA_TLS_CLIENT_CERT",
Some(pki.join("client.crt").to_string_lossy().as_ref()),
),
(
"LESAVKA_TLS_CLIENT_KEY",
Some(pki.join("client.key").to_string_lossy().as_ref()),
),
],
|| {
let err = endpoint("https://lesavka-server:50051")
.expect_err("fake explicit PEM should be rejected after file discovery");
assert!(err.to_string().contains("configuring relay TLS"));
},
);
}
2026-04-30 11:38:16 -03:00
#[test]
fn https_endpoint_fails_fast_without_client_enrollment() {
let dir = tempdir().expect("empty pki dir");
with_vars(
[
("HOME", Some(dir.path().to_string_lossy().as_ref())),
("LESAVKA_TLS_CA", None::<&str>),
("LESAVKA_TLS_CLIENT_CERT", None::<&str>),
("LESAVKA_TLS_CLIENT_KEY", None::<&str>),
("LESAVKA_TLS_DOMAIN", None::<&str>),
],
|| {
let err = endpoint("https://38.28.125.112:50051")
.expect_err("missing mTLS enrollment should be explicit");
assert!(err.to_string().contains("TLS enrollment is missing"));
assert!(err.to_string().contains("client.key"));
},
);
}
2026-04-30 08:16:57 -03:00
}