use std::path::{Path, PathBuf};
use std::sync::Arc;
use rustls::ServerConfig;
use crate::error::ShellTunnelError;
use crate::Result;
#[derive(Debug, Clone)]
pub struct TlsFiles {
pub cert: PathBuf,
pub key: PathBuf,
}
pub const DEFAULT_CERT: &str = "shell-tunnel-cert.pem";
pub const DEFAULT_KEY: &str = "shell-tunnel-key.pem";
impl TlsFiles {
pub fn new(cert: impl Into<PathBuf>, key: impl Into<PathBuf>) -> Self {
Self {
cert: cert.into(),
key: key.into(),
}
}
pub fn default_paths() -> Self {
Self::new(DEFAULT_CERT, DEFAULT_KEY)
}
pub fn exist(&self) -> bool {
self.cert.exists() && self.key.exists()
}
pub fn ensure_self_signed(&self, names: &[String]) -> Result<bool> {
if self.exist() {
return Ok(false);
}
if names.is_empty() {
return Err(ShellTunnelError::Tls(
"a self-signed certificate needs at least one name to be valid for".to_string(),
));
}
let issued = rcgen::generate_simple_self_signed(names.to_vec())
.map_err(|e| ShellTunnelError::Tls(format!("cannot generate a certificate: {e}")))?;
write_new(&self.cert, issued.cert.pem().as_bytes())?;
write_new(&self.key, issued.signing_key.serialize_pem().as_bytes())?;
restrict(&self.key);
Ok(true)
}
pub fn fingerprint(&self) -> Result<String> {
let certs = read_certs(&self.cert)?;
let leaf = certs
.first()
.ok_or_else(|| ShellTunnelError::Tls("certificate file is empty".to_string()))?;
Ok(crate::fingerprint::of_certificate(leaf.as_ref()))
}
pub fn load(&self) -> Result<ServerConfig> {
install_crypto_provider();
let certs = read_certs(&self.cert)?;
let key = read_key(&self.key)?;
ServerConfig::builder()
.with_no_client_auth()
.with_single_cert(certs, key)
.map_err(|e| ShellTunnelError::Tls(format!("certificate and key do not match: {e}")))
}
}
fn write_new(path: &Path, contents: &[u8]) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent).map_err(|e| {
ShellTunnelError::Tls(format!("cannot create {}: {e}", parent.display()))
})?;
}
}
std::fs::write(path, contents)
.map_err(|e| ShellTunnelError::Tls(format!("cannot write {}: {e}", path.display())))
}
fn restrict(path: &Path) {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let _ = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600));
}
#[cfg(not(unix))]
{
let _ = path;
}
}
pub fn certificate_names(public_base: Option<&str>, bind: std::net::SocketAddr) -> Vec<String> {
let mut names = Vec::new();
if let Some(base) = public_base {
let after_scheme = base.split_once("://").map_or(base, |(_, rest)| rest);
if let Some(host) = after_scheme
.split('/')
.next()
.map(|host| host.rsplit_once(':').map_or(host, |(h, _)| h))
{
if !host.is_empty() {
names.push(host.to_string());
}
}
}
if let Ok(hostname) = std::env::var(if cfg!(windows) {
"COMPUTERNAME"
} else {
"HOSTNAME"
}) {
let hostname = hostname.trim();
if !hostname.is_empty() && !names.iter().any(|n| n == hostname) {
names.push(hostname.to_string());
}
}
if !bind.ip().is_unspecified() {
let ip = bind.ip().to_string();
if !names.contains(&ip) {
names.push(ip);
}
}
for fallback in ["localhost", "127.0.0.1"] {
if !names.iter().any(|n| n == fallback) {
names.push(fallback.to_string());
}
}
names
}
fn install_crypto_provider() {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| {
let _ = rustls::crypto::ring::default_provider().install_default();
});
}
fn read_certs(path: &Path) -> Result<Vec<rustls::pki_types::CertificateDer<'static>>> {
let pem = std::fs::read(path).map_err(|e| {
ShellTunnelError::Tls(format!("cannot read certificate {}: {e}", path.display()))
})?;
let certs: std::result::Result<Vec<_>, _> =
rustls_pemfile::certs(&mut pem.as_slice()).collect();
let certs = certs.map_err(|e| {
ShellTunnelError::Tls(format!("{} is not a PEM certificate: {e}", path.display()))
})?;
if certs.is_empty() {
return Err(ShellTunnelError::Tls(format!(
"{} contains no certificate",
path.display()
)));
}
Ok(certs)
}
fn read_key(path: &Path) -> Result<rustls::pki_types::PrivateKeyDer<'static>> {
let pem = std::fs::read(path)
.map_err(|e| ShellTunnelError::Tls(format!("cannot read key {}: {e}", path.display())))?;
rustls_pemfile::private_key(&mut pem.as_slice())
.map_err(|e| ShellTunnelError::Tls(format!("{} is not a PEM key: {e}", path.display())))?
.ok_or_else(|| ShellTunnelError::Tls(format!("{} contains no private key", path.display())))
}
pub fn acceptor(config: ServerConfig) -> axum_server::tls_rustls::RustlsConfig {
axum_server::tls_rustls::RustlsConfig::from_config(Arc::new(config))
}
const RELOAD_INTERVAL: std::time::Duration = std::time::Duration::from_secs(60);
pub fn watch(files: TlsFiles, acceptor: axum_server::tls_rustls::RustlsConfig) {
tokio::spawn(async move {
let mut last = modified_at(&files);
loop {
tokio::time::sleep(RELOAD_INTERVAL).await;
let current = modified_at(&files);
if current == last {
continue;
}
last = current;
match files.load() {
Ok(config) => {
acceptor.reload_from_config(Arc::new(config));
tracing::info!(target: "tls", "reloaded {}", files.cert.display());
}
Err(e) => {
tracing::warn!(target: "tls", "keeping the previous certificate: {e}");
}
}
}
});
}
fn modified_at(files: &TlsFiles) -> (Option<std::time::SystemTime>, Option<std::time::SystemTime>) {
let stamp = |path: &Path| std::fs::metadata(path).and_then(|m| m.modified()).ok();
(stamp(&files.cert), stamp(&files.key))
}
#[cfg(test)]
mod tests {
use super::*;
fn self_signed(dir: &Path) -> TlsFiles {
let cert = rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
let cert_path = dir.join("cert.pem");
let key_path = dir.join("key.pem");
std::fs::write(&cert_path, cert.cert.pem()).unwrap();
std::fs::write(&key_path, cert.signing_key.serialize_pem()).unwrap();
TlsFiles::new(cert_path, key_path)
}
#[test]
fn a_generated_certificate_is_immediately_usable() {
let dir = tempfile::tempdir().unwrap();
let files = TlsFiles::new(dir.path().join("c.pem"), dir.path().join("k.pem"));
assert!(files
.ensure_self_signed(&["localhost".to_string()])
.unwrap());
assert!(files.exist());
assert!(files.load().is_ok());
}
#[test]
fn an_existing_certificate_is_reused_not_replaced() {
let dir = tempfile::tempdir().unwrap();
let files = TlsFiles::new(dir.path().join("c.pem"), dir.path().join("k.pem"));
files
.ensure_self_signed(&["localhost".to_string()])
.unwrap();
let original = std::fs::read_to_string(&files.cert).unwrap();
assert!(!files
.ensure_self_signed(&["localhost".to_string()])
.unwrap());
assert_eq!(original, std::fs::read_to_string(&files.cert).unwrap());
}
#[test]
fn generating_creates_missing_directories() {
let dir = tempfile::tempdir().unwrap();
let nested = dir.path().join("deep").join("deeper");
let files = TlsFiles::new(nested.join("c.pem"), nested.join("k.pem"));
files
.ensure_self_signed(&["localhost".to_string()])
.unwrap();
assert!(files.load().is_ok());
}
#[test]
fn a_certificate_needs_a_name() {
let dir = tempfile::tempdir().unwrap();
let files = TlsFiles::new(dir.path().join("c.pem"), dir.path().join("k.pem"));
let err = files.ensure_self_signed(&[]).unwrap_err().to_string();
assert!(err.contains("at least one name"), "{err}");
}
#[test]
fn the_public_base_is_the_first_name_a_certificate_gets() {
let bind = "0.0.0.0:8443".parse().unwrap();
let names = certificate_names(Some("https://relay.example.com:8443"), bind);
assert_eq!(names.first().map(String::as_str), Some("relay.example.com"));
assert!(names.iter().any(|n| n == "localhost"));
}
#[test]
fn without_a_public_base_the_local_names_still_work() {
let bind = "192.0.2.10:8443".parse().unwrap();
let names = certificate_names(None, bind);
assert!(names.iter().any(|n| n == "192.0.2.10"), "{names:?}");
assert!(names.iter().any(|n| n == "127.0.0.1"), "{names:?}");
}
#[test]
fn a_wildcard_bind_contributes_no_address() {
let bind = "0.0.0.0:8443".parse().unwrap();
let names = certificate_names(None, bind);
assert!(!names.iter().any(|n| n == "0.0.0.0"), "{names:?}");
}
#[test]
fn a_certificate_and_key_pair_loads() {
let dir = tempfile::tempdir().unwrap();
let files = self_signed(dir.path());
assert!(files.load().is_ok());
}
#[test]
fn a_missing_file_names_itself() {
let files = TlsFiles::new("nope-cert.pem", "nope-key.pem");
let err = files.load().unwrap_err().to_string();
assert!(err.contains("nope-cert.pem"), "{err}");
}
#[test]
fn a_non_pem_file_is_reported_as_such() {
let dir = tempfile::tempdir().unwrap();
let cert_path = dir.path().join("garbage.pem");
std::fs::write(&cert_path, b"this is not a certificate").unwrap();
let files = TlsFiles::new(&cert_path, &cert_path);
let err = files.load().unwrap_err().to_string();
assert!(err.contains("no certificate"), "{err}");
}
#[test]
fn replacing_a_certificate_changes_the_modification_signal() {
let dir = tempfile::tempdir().unwrap();
let files = self_signed(dir.path());
let before = modified_at(&files);
std::thread::sleep(std::time::Duration::from_millis(1100));
let replacement =
rcgen::generate_simple_self_signed(vec!["localhost".to_string()]).unwrap();
std::fs::write(&files.cert, replacement.cert.pem()).unwrap();
std::fs::write(&files.key, replacement.signing_key.serialize_pem()).unwrap();
assert_ne!(before, modified_at(&files), "a replacement must be noticed");
assert!(files.load().is_ok(), "the replacement should load");
}
#[test]
fn a_missing_file_reports_no_timestamp_rather_than_panicking() {
let files = TlsFiles::new("no-such-cert.pem", "no-such-key.pem");
assert_eq!(modified_at(&files), (None, None));
}
#[test]
fn a_mismatched_key_is_rejected() {
let dir = tempfile::tempdir().unwrap();
let first = self_signed(dir.path());
let second_dir = tempfile::tempdir().unwrap();
let second = self_signed(second_dir.path());
let mixed = TlsFiles::new(&first.cert, &second.key);
let err = mixed.load().unwrap_err().to_string();
assert!(err.contains("do not match"), "{err}");
}
}