use std::path::Path;
use std::sync::Arc;
use std::time::Duration;
use rcgen::{CertificateParams, DnType, ExtendedKeyUsagePurpose, IsCa, KeyPair, KeyUsagePurpose};
use time::OffsetDateTime;
use tokio_rustls::TlsAcceptor;
use tracing::{info, warn};
use url::{Host, Url};
use crate::config::{ServerConfig, TlsConfig};
use crate::pemfile;
const SELF_SIGNED_VALIDITY_DAYS: i64 = 3653;
const CLOCK_SKEW_ALLOWANCE: time::Duration = time::Duration::hours(1);
pub fn from_config(cfg: &ServerConfig) -> anyhow::Result<Option<TlsAcceptor>> {
if !cfg.tls.enabled {
warn!(
event = "tls_disabled",
outcome = "advisory",
"serving ACME in cleartext: RFC 8555 §6.1 expects HTTPS, so either \
enable server.tls or terminate TLS in front of this server"
);
return Ok(None);
}
if cfg.base_url.starts_with("http://") {
warn!(event = "tls_base_url_mismatch", outcome = "advisory", base_url = ?cfg.base_url,
"server.base_url names http:// while TLS is enabled: signed requests \
will be refused until it names https://");
}
Ok(Some(acceptor_from(
&cfg.tls,
&cfg.base_url,
&cfg.bind_address,
"acme",
)?))
}
pub fn admin_from_config(cfg: &crate::config::AdminConfig) -> anyhow::Result<Option<TlsAcceptor>> {
if !cfg.enabled || !cfg.tls.enabled {
return Ok(None);
}
let tls = TlsConfig {
enabled: cfg.tls.enabled,
cert_path: cfg.tls.cert_path.clone(),
key_path: cfg.tls.key_path.clone(),
handshake_timeout_ms: cfg.tls.handshake_timeout_ms,
};
Ok(Some(acceptor_from(
&tls,
&cfg.base_url,
&cfg.bind_address,
"admin",
)?))
}
fn acceptor_from(
cfg: &TlsConfig,
host_url: &str,
bind_address: &str,
listener: &'static str,
) -> anyhow::Result<TlsAcceptor> {
let cert_path = Path::new(&cfg.cert_path);
let key_path = Path::new(&cfg.key_path);
if cert_path.exists() && key_path.exists() {
pemfile::warn_if_key_is_readable("tls_key_permissive", key_path);
info!(event = "tls_cert_loaded", outcome = "success", listener = listener, cert_path = ?cfg.cert_path);
} else {
let (cert_pem, key_pem) = generate_self_signed(host_url)?;
std::fs::write(cert_path, &cert_pem)
.map_err(|error| anyhow::anyhow!("{}: {error}", cert_path.display()))?;
pemfile::write_private_key(key_path, &key_pem)?;
info!(event = "tls_cert_generated", outcome = "success", listener = listener, cert_path = ?cfg.cert_path, key_path = ?cfg.key_path);
}
let chain = pemfile::read_certificates(cert_path)?;
let key = pemfile::read_private_key(key_path)?;
let provider = rustls::crypto::ring::default_provider();
let mut config = rustls::ServerConfig::builder_with_provider(Arc::new(provider))
.with_safe_default_protocol_versions()
.map_err(|error| anyhow::anyhow!("building the TLS server configuration: {error}"))?
.with_no_client_auth()
.with_single_cert(chain, key)
.map_err(|error| {
anyhow::anyhow!(
"{} and {} are not a usable certificate/key pair: {error}",
cert_path.display(),
key_path.display()
)
})?;
config.alpn_protocols = vec![b"http/1.1".to_vec()];
info!(event = "tls_enabled", outcome = "success", listener = listener, bind_address = ?bind_address, cert_path = ?cfg.cert_path);
Ok(TlsAcceptor::from(Arc::new(config)))
}
fn generate_self_signed(base_url: &str) -> anyhow::Result<(String, String)> {
let url = Url::parse(base_url)
.map_err(|error| anyhow::anyhow!("server.base_url is not a URL: {error}"))?;
let host = match url
.host()
.ok_or_else(|| anyhow::anyhow!("server.base_url has no host: {base_url}"))?
{
Host::Domain(name) => name.to_string(),
Host::Ipv4(address) => address.to_string(),
Host::Ipv6(address) => address.to_string(),
};
let key_pair = KeyPair::generate()?;
let mut params = CertificateParams::new(vec![host.clone()])?;
params
.distinguished_name
.push(DnType::CommonName, host.clone());
params.is_ca = IsCa::NoCa;
params.key_usages = vec![
KeyUsagePurpose::DigitalSignature,
KeyUsagePurpose::KeyEncipherment,
];
params.extended_key_usages = vec![ExtendedKeyUsagePurpose::ServerAuth];
let now = OffsetDateTime::now_utc();
params.not_before = now - CLOCK_SKEW_ALLOWANCE;
params.not_after = now + time::Duration::days(SELF_SIGNED_VALIDITY_DAYS);
let certificate = params.self_signed(&key_pair)?;
Ok((certificate.pem(), key_pair.serialize_pem()))
}
#[derive(Clone)]
pub struct TlsSettings {
pub acceptor: TlsAcceptor,
pub handshake_timeout: Duration,
}
impl TlsSettings {
#[must_use]
pub fn new(acceptor: TlsAcceptor, handshake_timeout: Duration) -> Self {
Self {
acceptor,
handshake_timeout,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::TlsConfig;
use crate::testutil::TempDir;
use std::fs;
fn tls_config(dir: &TempDir, base_url: &str) -> ServerConfig {
ServerConfig {
bind_address: "127.0.0.1:0".to_string(),
base_url: base_url.to_string(),
tls: TlsConfig {
enabled: true,
cert_path: dir.join("server.pem").display().to_string(),
key_path: dir.join("server.key").display().to_string(),
handshake_timeout_ms: 5_000,
},
..ServerConfig::default()
}
}
fn startup_error(result: anyhow::Result<Option<TlsAcceptor>>) -> String {
match result {
Err(error) => error.to_string(),
Ok(_) => panic!("this configuration must not build"),
}
}
fn admin_config(dir: &TempDir, base_url: &str) -> crate::config::AdminConfig {
crate::config::AdminConfig {
enabled: true,
bind_address: "127.0.0.1:0".to_string(),
base_url: base_url.to_string(),
tls: crate::config::AdminTlsConfig {
enabled: true,
cert_path: dir.join("admin.pem").display().to_string(),
key_path: dir.join("admin.key").display().to_string(),
handshake_timeout_ms: 5_000,
},
..crate::config::AdminConfig::default()
}
}
#[test]
fn the_admin_acceptor_is_absent_when_the_panel_or_its_tls_is_off() {
for (panel, tls) in [(false, true), (true, false), (false, false)] {
let dir = TempDir::new("tls-admin");
let mut cfg = admin_config(&dir, "https://localhost:3001");
cfg.enabled = panel;
cfg.tls.enabled = tls;
assert!(
admin_from_config(&cfg).unwrap().is_none(),
"enabled={panel} tls={tls} must build no acceptor"
);
assert!(!Path::new(&cfg.tls.cert_path).exists());
assert!(!Path::new(&cfg.tls.key_path).exists());
}
}
#[test]
fn the_admin_certificate_is_generated_reloaded_and_names_its_own_host() {
use x509_parser::prelude::*;
let dir = TempDir::new("tls-admin");
let cfg = admin_config(&dir, "https://panel.example.test:3001");
assert!(admin_from_config(&cfg).unwrap().is_some());
let generated = fs::read(&cfg.tls.cert_path).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&cfg.tls.key_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "the generated key was {mode:o}");
}
assert!(admin_from_config(&cfg).unwrap().is_some());
assert_eq!(fs::read(&cfg.tls.cert_path).unwrap(), generated);
let chain = pemfile::read_certificates(Path::new(&cfg.tls.cert_path)).unwrap();
let (_, certificate) = X509Certificate::from_der(&chain[0]).unwrap();
let names: Vec<_> = certificate
.subject_alternative_name()
.unwrap()
.unwrap()
.value
.general_names
.iter()
.map(|name| format!("{name:?}"))
.collect();
assert!(names[0].contains("panel.example.test"), "{names:?}");
}
#[test]
fn a_mismatched_admin_pair_is_a_startup_error() {
let acme_dir = TempDir::new("tls-acme");
let admin_dir = TempDir::new("tls-admin");
let acme = tls_config(&acme_dir, "https://localhost:3000");
from_config(&acme).unwrap();
let cfg = admin_config(&admin_dir, "https://localhost:3001");
admin_from_config(&cfg).unwrap();
let crossed = crate::config::AdminConfig {
tls: crate::config::AdminTlsConfig {
key_path: acme.tls.key_path.clone(),
..cfg.tls.clone()
},
..cfg
};
let error = startup_error(admin_from_config(&crossed));
assert!(
error.contains("not a usable certificate/key pair"),
"got: {error}"
);
}
#[test]
fn the_two_listeners_provision_independent_certificates() {
let dir = TempDir::new("tls-both");
let acme = ServerConfig {
bind_address: "127.0.0.1:0".to_string(),
base_url: "https://acme.example.test".to_string(),
tls: TlsConfig {
enabled: true,
cert_path: dir.join("server.pem").display().to_string(),
key_path: dir.join("server.key").display().to_string(),
handshake_timeout_ms: 5_000,
},
..ServerConfig::default()
};
let admin = admin_config(&dir, "https://panel.example.test");
from_config(&acme).unwrap();
admin_from_config(&admin).unwrap();
assert_ne!(
fs::read(&acme.tls.cert_path).unwrap(),
fs::read(&admin.tls.cert_path).unwrap(),
"the two listeners answer to different names and must not share a certificate"
);
}
#[test]
fn disabled_builds_nothing_and_touches_no_disk() {
let dir = TempDir::new("tls");
let mut cfg = tls_config(&dir, "http://localhost:3000");
cfg.tls.enabled = false;
assert!(from_config(&cfg).unwrap().is_none());
assert!(!Path::new(&cfg.tls.cert_path).exists());
assert!(!Path::new(&cfg.tls.key_path).exists());
}
#[test]
fn a_missing_certificate_is_generated_then_reloaded() {
let dir = TempDir::new("tls");
let cfg = tls_config(&dir, "https://localhost:3000");
assert!(from_config(&cfg).unwrap().is_some());
let generated = fs::read(&cfg.tls.cert_path).unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = fs::metadata(&cfg.tls.key_path)
.unwrap()
.permissions()
.mode()
& 0o777;
assert_eq!(mode, 0o600, "the generated key was {mode:o}");
}
assert!(from_config(&cfg).unwrap().is_some());
assert_eq!(fs::read(&cfg.tls.cert_path).unwrap(), generated);
}
#[test]
fn the_generated_certificate_names_the_base_url_host() {
use x509_parser::prelude::*;
let dir = TempDir::new("tls");
let cfg = tls_config(&dir, "https://acme.example.test:8443");
from_config(&cfg).unwrap();
let chain = pemfile::read_certificates(Path::new(&cfg.tls.cert_path)).unwrap();
let (_, certificate) = X509Certificate::from_der(&chain[0]).unwrap();
let names: Vec<_> = certificate
.subject_alternative_name()
.unwrap()
.unwrap()
.value
.general_names
.iter()
.map(|name| format!("{name:?}"))
.collect();
assert_eq!(names.len(), 1, "{names:?}");
assert!(names[0].contains("acme.example.test"), "{names:?}");
assert!(!names[0].contains("8443"), "{names:?}");
}
#[test]
fn an_ip_base_url_yields_an_ip_san() {
use x509_parser::prelude::*;
let dir = TempDir::new("tls");
let cfg = tls_config(&dir, "https://127.0.0.1:3000");
from_config(&cfg).unwrap();
let chain = pemfile::read_certificates(Path::new(&cfg.tls.cert_path)).unwrap();
let (_, certificate) = X509Certificate::from_der(&chain[0]).unwrap();
let general_names = certificate.subject_alternative_name().unwrap().unwrap();
assert!(
general_names
.value
.general_names
.iter()
.any(|name| matches!(name, GeneralName::IPAddress(_))),
"{:?}",
general_names.value.general_names
);
}
#[test]
fn a_base_url_without_a_host_is_a_startup_error() {
let dir = TempDir::new("tls");
let cfg = tls_config(&dir, "not-a-url");
let error = startup_error(from_config(&cfg));
assert!(error.contains("server.base_url"), "{error}");
}
#[test]
fn a_mismatched_pair_is_a_startup_error() {
let dir = TempDir::new("tls");
let cfg = tls_config(&dir, "https://localhost:3000");
let (cert_pem, _) = generate_self_signed("https://localhost").unwrap();
let (_, key_pem) = generate_self_signed("https://localhost").unwrap();
fs::write(&cfg.tls.cert_path, cert_pem).unwrap();
fs::write(&cfg.tls.key_path, key_pem).unwrap();
let error = startup_error(from_config(&cfg));
assert!(
error.contains("not a usable certificate/key pair"),
"{error}"
);
}
#[test]
fn a_certificate_file_without_a_certificate_is_a_startup_error() {
let dir = TempDir::new("tls");
let cfg = tls_config(&dir, "https://localhost:3000");
let (_, key_pem) = generate_self_signed("https://localhost").unwrap();
fs::write(&cfg.tls.cert_path, "nothing useful here\n").unwrap();
fs::write(&cfg.tls.key_path, key_pem).unwrap();
let error = startup_error(from_config(&cfg));
assert!(error.contains("no CERTIFICATE block"), "{error}");
}
}