258 lines
8.9 KiB
Rust
258 lines
8.9 KiB
Rust
|
|
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();
|
||
|
|
|
||
|
|
if let Some(ca_path) = env_path("LESAVKA_TLS_CA").or_else(|| default_pki_path("ca.crt"))
|
||
|
|
&& ca_path.exists()
|
||
|
|
{
|
||
|
|
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_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"));
|
||
|
|
if let (Some(cert_path), Some(key_path)) = (cert_path, key_path)
|
||
|
|
&& cert_path.exists()
|
||
|
|
&& key_path.exists()
|
||
|
|
{
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
|
||
|
|
Ok(tls)
|
||
|
|
}
|
||
|
|
|
||
|
|
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"));
|
||
|
|
},
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|