diff --git a/rpxy-acme/Cargo.toml b/rpxy-acme/Cargo.toml index 4ceeaf37..57dd5145 100644 --- a/rpxy-acme/Cargo.toml +++ b/rpxy-acme/Cargo.toml @@ -15,3 +15,17 @@ url = { version = "2.5.2" } rustc-hash = "2.0.0" thiserror = "1.0.62" tracing = "0.1.40" +async-trait = "0.1.81" +base64 = "0.22.1" +aws-lc-rs = { version = "1.8.0", default-features = false, features = [ + "aws-lc-sys", +] } +blocking = "1.6.1" +rustls = { version = "0.23.11", default-features = false, features = [ + "std", + "aws_lc_rs", +] } +rustls-platform-verifier = { version = "0.3.2" } +rustls-acme = { path = "../../rustls-acme/", default-features = false, features = [ + "aws-lc-rs", +] } diff --git a/rpxy-acme/src/constants.rs b/rpxy-acme/src/constants.rs index edb657b9..7b544b0b 100644 --- a/rpxy-acme/src/constants.rs +++ b/rpxy-acme/src/constants.rs @@ -5,10 +5,4 @@ pub const ACME_DIR_URL: &str = "https://acme-v02.api.letsencrypt.org/directory"; pub const ACME_REGISTRY_PATH: &str = "./acme_registry"; /// ACME accounts directory, subdirectory of ACME_REGISTRY_PATH -pub(crate) const ACME_ACCOUNT_SUBDIR: &str = "account"; - -/// ACME private key file name -pub const ACME_PRIVATE_KEY_FILE_NAME: &str = "private_key.pem"; - -/// ACME certificate file name -pub const ACME_CERTIFICATE_FILE_NAME: &str = "certificate.pem"; +pub(crate) const ACME_ACCOUNT_SUBDIR: &str = "accounts"; diff --git a/rpxy-acme/src/dir_cache.rs b/rpxy-acme/src/dir_cache.rs new file mode 100644 index 00000000..2f613d8d --- /dev/null +++ b/rpxy-acme/src/dir_cache.rs @@ -0,0 +1,107 @@ +use crate::constants::ACME_ACCOUNT_SUBDIR; +use async_trait::async_trait; +use aws_lc_rs as crypto; +use base64::prelude::*; +use blocking::unblock; +use crypto::digest::{Context, SHA256}; +use rustls_acme::{AccountCache, CertCache}; +use std::{ + io::ErrorKind, + path::{Path, PathBuf}, +}; + +enum FileType { + Account, + Cert, +} + +#[derive(Debug)] +pub struct DirCache { + account_dir: PathBuf, + cert_dir: PathBuf, +} + +impl DirCache { + pub fn new

(dir: P, server_name: impl AsRef) -> Self + where + P: AsRef, + { + Self { + account_dir: dir.as_ref().join(ACME_ACCOUNT_SUBDIR), + cert_dir: dir.as_ref().join(server_name), + } + } + async fn read_if_exist(&self, file: impl AsRef, file_type: FileType) -> Result>, std::io::Error> { + let subdir = match file_type { + FileType::Account => &self.account_dir, + FileType::Cert => &self.cert_dir, + }; + let file_path = subdir.join(file); + match unblock(move || std::fs::read(file_path)).await { + Ok(content) => Ok(Some(content)), + Err(err) => match err.kind() { + ErrorKind::NotFound => Ok(None), + _ => Err(err), + }, + } + } + async fn write(&self, file: impl AsRef, contents: impl AsRef<[u8]>, file_type: FileType) -> Result<(), std::io::Error> { + let subdir = match file_type { + FileType::Account => &self.account_dir, + FileType::Cert => &self.cert_dir, + } + .clone(); + let subdir_clone = subdir.clone(); + unblock(move || std::fs::create_dir_all(subdir_clone)).await?; + let file_path = subdir.join(file); + let contents = contents.as_ref().to_owned(); + unblock(move || std::fs::write(file_path, contents)).await + } + pub fn cached_account_file_name(contact: &[String], directory_url: impl AsRef) -> String { + let mut ctx = Context::new(&SHA256); + for el in contact { + ctx.update(el.as_ref()); + ctx.update(&[0]) + } + ctx.update(directory_url.as_ref().as_bytes()); + let hash = BASE64_URL_SAFE_NO_PAD.encode(ctx.finish()); + format!("cached_account_{}", hash) + } + pub fn cached_cert_file_name(domains: &[String], directory_url: impl AsRef) -> String { + let mut ctx = Context::new(&SHA256); + for domain in domains { + ctx.update(domain.as_ref()); + ctx.update(&[0]) + } + ctx.update(directory_url.as_ref().as_bytes()); + let hash = BASE64_URL_SAFE_NO_PAD.encode(ctx.finish()); + format!("cached_cert_{}", hash) + } +} + +#[async_trait] +impl CertCache for DirCache { + type EC = std::io::Error; + async fn load_cert(&self, domains: &[String], directory_url: &str) -> Result>, Self::EC> { + let file_name = Self::cached_cert_file_name(domains, directory_url); + self.read_if_exist(file_name, FileType::Cert).await + } + async fn store_cert(&self, domains: &[String], directory_url: &str, cert: &[u8]) -> Result<(), Self::EC> { + let file_name = Self::cached_cert_file_name(domains, directory_url); + self.write(file_name, cert, FileType::Cert).await + } +} + +#[async_trait] +impl AccountCache for DirCache { + type EA = std::io::Error; + async fn load_account(&self, contact: &[String], directory_url: &str) -> Result>, Self::EA> { + let file_name = Self::cached_account_file_name(contact, directory_url); + self.read_if_exist(file_name, FileType::Account).await + } + + async fn store_account(&self, contact: &[String], directory_url: &str, account: &[u8]) -> Result<(), Self::EA> { + let file_name = Self::cached_account_file_name(contact, directory_url); + self.write(file_name, account, FileType::Account).await + } +} diff --git a/rpxy-acme/src/lib.rs b/rpxy-acme/src/lib.rs index 1fec84c6..813b388a 100644 --- a/rpxy-acme/src/lib.rs +++ b/rpxy-acme/src/lib.rs @@ -1,4 +1,5 @@ mod constants; +mod dir_cache; mod error; mod targets; @@ -7,6 +8,7 @@ mod log { pub(super) use tracing::{debug, error, info, warn}; } -pub use constants::{ACME_CERTIFICATE_FILE_NAME, ACME_DIR_URL, ACME_PRIVATE_KEY_FILE_NAME, ACME_REGISTRY_PATH}; +pub use constants::{ACME_DIR_URL, ACME_REGISTRY_PATH}; +pub use dir_cache::DirCache; pub use error::RpxyAcmeError; -pub use targets::AcmeTargets; +pub use targets::AcmeContexts; diff --git a/rpxy-acme/src/targets.rs b/rpxy-acme/src/targets.rs index 0cc6f99f..08c46852 100644 --- a/rpxy-acme/src/targets.rs +++ b/rpxy-acme/src/targets.rs @@ -1,83 +1,96 @@ -use rustc_hash::FxHashMap as HashMap; -use std::path::PathBuf; -use url::Url; - +use crate::dir_cache::DirCache; use crate::{ - constants::{ACME_ACCOUNT_SUBDIR, ACME_CERTIFICATE_FILE_NAME, ACME_DIR_URL, ACME_PRIVATE_KEY_FILE_NAME, ACME_REGISTRY_PATH}, + constants::{ACME_DIR_URL, ACME_REGISTRY_PATH}, error::RpxyAcmeError, log::*, }; +use rustc_hash::FxHashMap as HashMap; +use rustls_acme::AcmeConfig; +use std::{fmt::Debug, path::PathBuf, sync::Arc}; +use url::Url; #[derive(Debug)] /// ACME settings -pub struct AcmeTargets { - /// ACME account email - pub email: String, +pub struct AcmeContexts +where + EC: Debug + 'static, + EA: Debug + 'static, +{ /// ACME directory url - pub acme_dir_url: Url, - /// ACME registry path that stores account key and certificate - pub acme_registry_path: PathBuf, - /// ACME accounts directory, subdirectory of ACME_REGISTRY_PATH - pub acme_accounts_dir: PathBuf, - /// ACME target info map - pub acme_targets: HashMap, -} - -#[derive(Debug)] -/// ACME settings for each server name -pub struct AcmeTargetInfo { - /// Server name - pub server_name: String, - /// private key path - pub private_key_path: PathBuf, - /// certificate path - pub certificate_path: PathBuf, + acme_dir_url: Url, + /// ACME registry directory + acme_registry_dir: PathBuf, + /// ACME contacts + contacts: Vec, + /// ACME config + inner: HashMap>>, } -impl AcmeTargets { - /// Create a new instance - pub fn try_new(email: &str, acme_dir_url: Option<&str>, acme_registry_path: Option<&str>) -> Result { - let acme_dir_url = Url::parse(acme_dir_url.unwrap_or(ACME_DIR_URL))?; - let acme_registry_path = acme_registry_path.map_or_else(|| PathBuf::from(ACME_REGISTRY_PATH), PathBuf::from); - if acme_registry_path.exists() && !acme_registry_path.is_dir() { - return Err(RpxyAcmeError::InvalidAcmeRegistryPath); - } - let acme_account_dir = acme_registry_path.join(ACME_ACCOUNT_SUBDIR); - if acme_account_dir.exists() && !acme_account_dir.is_dir() { +impl AcmeContexts { + /// Create a new instance. Note that for each domain, a new AcmeConfig is created. + /// This means that for each domain, a distinct operation will be dispatched and separated certificates will be generated. + pub fn try_new( + acme_dir_url: Option<&str>, + acme_registry_dir: Option<&str>, + contacts: &[String], + domains: &[String], + ) -> Result { + let acme_registry_dir = acme_registry_dir + .map(|v| v.to_ascii_lowercase()) + .map_or_else(|| PathBuf::from(ACME_REGISTRY_PATH), PathBuf::from); + if acme_registry_dir.exists() && !acme_registry_dir.is_dir() { return Err(RpxyAcmeError::InvalidAcmeRegistryPath); } - std::fs::create_dir_all(&acme_account_dir)?; + let acme_dir_url = acme_dir_url + .map(|v| v.to_ascii_lowercase()) + .as_deref() + .map_or_else(|| Url::parse(ACME_DIR_URL), Url::parse)?; + let contacts = contacts.iter().map(|email| format!("mailto:{email}")).collect::>(); + let rustls_client_config = rustls::ClientConfig::builder() + .dangerous() // The `Verifier` we're using is actually safe + .with_custom_certificate_verifier(std::sync::Arc::new(rustls_platform_verifier::Verifier::new())) + .with_no_client_auth(); + let rustls_client_config = Arc::new(rustls_client_config); + + let inner = domains + .iter() + .map(|domain| { + let dir_cache = DirCache::new(&acme_registry_dir, domain); + let config = AcmeConfig::new([domain]) + .contact(&contacts) + .cache(dir_cache) + .directory(acme_dir_url.as_str()) + .client_tls_config(rustls_client_config.clone()); + let config = Box::new(config); + (domain.to_ascii_lowercase(), config) + }) + .collect::>(); Ok(Self { - email: email.to_owned(), acme_dir_url, - acme_registry_path, - acme_accounts_dir: acme_account_dir, - acme_targets: HashMap::default(), + acme_registry_dir, + contacts, + inner, }) } +} - /// Add a new target - /// Write dummy cert and key files if not exists - pub fn add_target(&mut self, server_name: &str) -> Result<(), RpxyAcmeError> { - info!("Adding ACME target: {}", server_name); - let parent_dir = self.acme_registry_path.join(server_name); - let private_key_path = parent_dir.join(ACME_PRIVATE_KEY_FILE_NAME); - let certificate_path = parent_dir.join(ACME_CERTIFICATE_FILE_NAME); - - if !parent_dir.exists() { - warn!("Creating ACME target directory: {}", parent_dir.display()); - std::fs::create_dir_all(parent_dir)?; - } +#[cfg(test)] +mod tests { + use super::*; - self.acme_targets.insert( - server_name.to_owned(), - AcmeTargetInfo { - server_name: server_name.to_owned(), - private_key_path, - certificate_path, - }, - ); - Ok(()) + #[test] + fn test_try_new() { + let acme_dir_url = "https://acme.example.com/directory"; + let acme_registry_dir = "/tmp/acme"; + let contacts = vec!["test@example.com".to_string()]; + let acme_contexts: AcmeContexts = AcmeContexts::try_new( + Some(acme_dir_url), + Some(acme_registry_dir), + &contacts, + &["example.com".to_string(), "example.org".to_string()], + ) + .unwrap(); + println!("{:#?}", acme_contexts); } } diff --git a/rpxy-bin/src/config/parse.rs b/rpxy-bin/src/config/parse.rs index 4a28beda..741cc7ae 100644 --- a/rpxy-bin/src/config/parse.rs +++ b/rpxy-bin/src/config/parse.rs @@ -7,7 +7,7 @@ use rpxy_lib::{AppConfig, AppConfigList, ProxyConfig}; use rustc_hash::FxHashMap as HashMap; #[cfg(feature = "acme")] -use rpxy_acme::{AcmeTargets, ACME_CERTIFICATE_FILE_NAME, ACME_PRIVATE_KEY_FILE_NAME, ACME_REGISTRY_PATH}; +use rpxy_acme::{ACME_DIR_URL, ACME_REGISTRY_PATH}; /// Parsed options pub struct Opts { @@ -110,7 +110,12 @@ pub async fn build_cert_manager( #[cfg(feature = "acme")] let acme_option = config.experimental.as_ref().and_then(|v| v.acme.clone()); #[cfg(feature = "acme")] - let registry_path = acme_option + let acme_dir_url = acme_option + .as_ref() + .and_then(|v| v.dir_url.as_deref()) + .unwrap_or(ACME_DIR_URL); + #[cfg(feature = "acme")] + let acme_registry_path = acme_option .as_ref() .and_then(|v| v.registry_path.as_deref()) .unwrap_or(ACME_REGISTRY_PATH); @@ -128,8 +133,12 @@ pub async fn build_cert_manager( let mut tls = tls.clone(); if let Some(true) = tls.acme { ensure!(acme_option.is_some() && tls.tls_cert_key_path.is_none() && tls.tls_cert_path.is_none()); - tls.tls_cert_key_path = Some(format!("{registry_path}/{server_name}/{ACME_CERTIFICATE_FILE_NAME}")); - tls.tls_cert_path = Some(format!("{registry_path}/{server_name}/{ACME_PRIVATE_KEY_FILE_NAME}")); + // Both of tls_cert_key_path and tls_cert_path must be the same for ACME since it's a single file + let subdir = format!("{}/{}", acme_registry_path, server_name.to_ascii_lowercase()); + let file_name = + rpxy_acme::DirCache::cached_cert_file_name(&[server_name.to_ascii_lowercase()], acme_dir_url.to_ascii_lowercase()); + tls.tls_cert_key_path = Some(format!("{}/{}", subdir, file_name)); + tls.tls_cert_path = Some(format!("{}/{}", subdir, file_name)); } tls }; @@ -151,27 +160,27 @@ pub async fn build_cert_manager( /// Build acme manager and dummy cert and key as initial states if not exists /// TODO: CURRENTLY NOT IMPLEMENTED, UNDER DESIGNING pub async fn build_acme_manager(config: &ConfigToml) -> Result<(), anyhow::Error> { - let acme_option = config.experimental.as_ref().and_then(|v| v.acme.clone()); - if acme_option.is_none() { - return Ok(()); - } - let acme_option = acme_option.unwrap(); - let mut acme_targets = AcmeTargets::try_new( - acme_option.email.as_ref(), - acme_option.dir_url.as_deref(), - acme_option.registry_path.as_deref(), - ) - .map_err(|e| anyhow!("Invalid acme configuration: {e}"))?; - - let apps = config.apps.as_ref().unwrap(); - for app in apps.0.values() { - if let Some(tls) = app.tls.as_ref() { - if tls.acme.unwrap_or(false) { - acme_targets.add_target(app.server_name.as_ref().unwrap())?; - } - } - } + // let acme_option = config.experimental.as_ref().and_then(|v| v.acme.clone()); + // if acme_option.is_none() { + // return Ok(()); + // } + // let acme_option = acme_option.unwrap(); + // let mut acme_targets = AcmeTargets::try_new( + // acme_option.email.as_ref(), + // acme_option.dir_url.as_deref(), + // acme_option.registry_path.as_deref(), + // ) + // .map_err(|e| anyhow!("Invalid acme configuration: {e}"))?; + + // let apps = config.apps.as_ref().unwrap(); + // for app in apps.0.values() { + // if let Some(tls) = app.tls.as_ref() { + // if tls.acme.unwrap_or(false) { + // acme_targets.add_target(app.server_name.as_ref().unwrap())?; + // } + // } + // } // TODO: remove later - println!("ACME targets: {:#?}", acme_targets); + // println!("ACME targets: {:#?}", acme_targets); Ok(()) }