lesavka/server/src/security.rs

212 lines
7.3 KiB
Rust

use anyhow::{Context, Result, bail};
use std::path::PathBuf;
use tonic::transport::{Certificate, Identity, ServerTlsConfig};
use tracing::{info, warn};
const DEFAULT_PKI_DIR: &str = "/etc/lesavka/pki";
pub fn server_tls_config() -> Result<Option<ServerTlsConfig>> {
let cert_path = env_path("LESAVKA_TLS_CERT").unwrap_or_else(|| default_path("server.crt"));
let key_path = env_path("LESAVKA_TLS_KEY").unwrap_or_else(|| default_path("server.key"));
let require_tls = bool_env("LESAVKA_REQUIRE_TLS");
if !cert_path.exists() || !key_path.exists() {
if require_tls {
bail!(
"LESAVKA_REQUIRE_TLS=1 but TLS identity files are missing: cert={} key={}",
cert_path.display(),
key_path.display()
);
}
warn!(
cert = %cert_path.display(),
key = %key_path.display(),
"🔓 TLS identity not present; serving plaintext gRPC for local/dev use"
);
return Ok(None);
}
let cert = std::fs::read(&cert_path)
.with_context(|| format!("reading TLS server certificate {}", cert_path.display()))?;
let key = std::fs::read(&key_path)
.with_context(|| format!("reading TLS server key {}", key_path.display()))?;
let mut tls = ServerTlsConfig::new().identity(Identity::from_pem(cert, key));
let client_ca_path =
env_path("LESAVKA_TLS_CLIENT_CA").unwrap_or_else(|| default_path("ca.crt"));
if client_ca_path.exists() {
let client_ca = std::fs::read(&client_ca_path)
.with_context(|| format!("reading TLS client CA {}", client_ca_path.display()))?;
tls = tls.client_ca_root(Certificate::from_pem(client_ca));
if bool_env("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL") {
tls = tls.client_auth_optional(true);
warn!("🔐 TLS enabled with optional client certificates");
} else {
info!("🔐 TLS enabled with required client certificate authentication");
}
} else {
warn!(
ca = %client_ca_path.display(),
"🔐 TLS enabled without client certificate authentication; install a client CA for production"
);
}
Ok(Some(tls))
}
fn env_path(name: &str) -> Option<PathBuf> {
std::env::var_os(name)
.filter(|value| !value.is_empty())
.map(PathBuf::from)
}
fn default_path(file_name: &str) -> PathBuf {
PathBuf::from(DEFAULT_PKI_DIR).join(file_name)
}
fn bool_env(name: &str) -> bool {
std::env::var(name)
.map(|value| {
let value = value.trim().to_ascii_lowercase();
!value.is_empty() && value != "0" && value != "false" && value != "no"
})
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::{bool_env, default_path, env_path, server_tls_config};
use temp_env::with_vars;
use tempfile::tempdir;
#[test]
fn missing_identity_is_allowed_unless_tls_is_required() {
with_vars(
[
("LESAVKA_REQUIRE_TLS", None::<&str>),
("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")),
("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")),
],
|| {
assert!(
server_tls_config()
.expect("optional tls should not error")
.is_none()
)
},
);
}
#[test]
fn require_tls_errors_when_identity_is_missing() {
with_vars(
[
("LESAVKA_REQUIRE_TLS", Some("1")),
("LESAVKA_TLS_CERT", Some("/tmp/lesavka-missing-cert")),
("LESAVKA_TLS_KEY", Some("/tmp/lesavka-missing-key")),
],
|| {
let err = server_tls_config().expect_err("required tls should fail");
assert!(err.to_string().contains("LESAVKA_REQUIRE_TLS=1"));
},
);
}
#[test]
fn helpers_parse_paths_and_boolean_overrides() {
with_vars(
[
("LESAVKA_TLS_CERT", Some("/tmp/server.crt")),
("LESAVKA_REQUIRE_TLS", Some("yes")),
],
|| {
assert_eq!(
env_path("LESAVKA_TLS_CERT").unwrap(),
std::path::PathBuf::from("/tmp/server.crt")
);
assert!(bool_env("LESAVKA_REQUIRE_TLS"));
},
);
with_vars(
[
("LESAVKA_TLS_CERT", Some("")),
("LESAVKA_REQUIRE_TLS", Some("false")),
],
|| {
assert!(env_path("LESAVKA_TLS_CERT").is_none());
assert!(!bool_env("LESAVKA_REQUIRE_TLS"));
assert_eq!(
default_path("server.crt"),
std::path::PathBuf::from("/etc/lesavka/pki/server.crt")
);
},
);
}
#[test]
fn tls_identity_can_load_with_and_without_client_ca() {
let dir = tempdir().expect("tls dir");
let cert = dir.path().join("server.crt");
let key = dir.path().join("server.key");
let ca = dir.path().join("ca.crt");
std::fs::write(&cert, b"not a real cert").expect("cert");
std::fs::write(&key, b"not a real key").expect("key");
std::fs::write(&ca, b"not a real ca").expect("ca");
with_vars(
[
("LESAVKA_REQUIRE_TLS", Some("1")),
("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())),
("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())),
("LESAVKA_TLS_CLIENT_CA", Some(ca.to_string_lossy().as_ref())),
("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("1")),
],
|| {
assert!(server_tls_config().expect("tls config").is_some());
},
);
with_vars(
[
("LESAVKA_REQUIRE_TLS", Some("1")),
("LESAVKA_TLS_CERT", Some(cert.to_string_lossy().as_ref())),
("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())),
(
"LESAVKA_TLS_CLIENT_CA",
Some(dir.path().join("missing-ca.crt").to_string_lossy().as_ref()),
),
("LESAVKA_TLS_CLIENT_AUTH_OPTIONAL", Some("0")),
],
|| {
assert!(
server_tls_config()
.expect("tls config without ca")
.is_some()
);
},
);
}
#[test]
fn existing_but_unreadable_identity_reports_context() {
let dir = tempdir().expect("tls dir");
let key = dir.path().join("server.key");
std::fs::write(&key, b"not a real key").expect("key");
with_vars(
[
("LESAVKA_REQUIRE_TLS", Some("1")),
(
"LESAVKA_TLS_CERT",
Some(dir.path().to_string_lossy().as_ref()),
),
("LESAVKA_TLS_KEY", Some(key.to_string_lossy().as_ref())),
],
|| {
let err = server_tls_config().expect_err("directory cert should fail");
assert!(err.to_string().contains("reading TLS server certificate"));
},
);
}
}