From 6d8799ce8c7eff26cc98e518d8931f3197f22e74 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Thu, 18 May 2023 22:02:57 +0200 Subject: [PATCH] Got acme manager implemented, still untested. Not hooked up to user domains yet. --- .env.dev | 2 + src/domain.rs | 2 +- src/domain/acme/manager.rs | 180 +++++++++++++++++++++++++++++++++++-- src/domain/acme/store.rs | 57 +++++++----- src/domain/manager.rs | 46 ++++++++-- src/main.rs | 64 ++++++++++++- src/service.rs | 15 ++++ 7 files changed, 322 insertions(+), 44 deletions(-) diff --git a/.env.dev b/.env.dev index f731445..8de4c39 100644 --- a/.env.dev +++ b/.env.dev @@ -3,3 +3,5 @@ export DOMIPLY_PASSPHRASE=foobar export DOMIPLY_ORIGIN_STORE_GIT_DIR_PATH=/tmp/domiply_dev_env/origin/git export DOMIPLY_DOMAIN_CHECKER_TARGET_A=127.0.0.1 export DOMIPLY_DOMAIN_CONFIG_STORE_DIR_PATH=/tmp/domiply_dev_env/domain/config +export DOMIPLY_DOMAIN_ACME_STORE_DIR_PATH=/tmp/domiply_dev_env/domain/acme +export DOMIPLY_DOMAIN_ACME_CONTACT_EMAIL=domiply@example.com diff --git a/src/domain.rs b/src/domain.rs index 10f2373..7fbe9fd 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -16,7 +16,7 @@ pub struct Name { } impl Name { - fn as_str(&self) -> &str { + pub fn as_str(&self) -> &str { self.utf8_str.as_str() } } diff --git a/src/domain/acme/manager.rs b/src/domain/acme/manager.rs index d94c916..b1f4ddb 100644 --- a/src/domain/acme/manager.rs +++ b/src/domain/acme/manager.rs @@ -1,15 +1,30 @@ -use std::sync; +use std::{future, pin, sync, time}; -use crate::domain::acme; +use crate::domain::{self, acme}; use crate::error; use crate::error::{MapUnexpected, ToUnexpected}; const LETS_ENCRYPT_URL: &'static str = "https://acme-v02.api.letsencrypt.org/directory"; -#[mockall::automock] -pub trait Manager {} +pub type GetHttp01ChallengeKeyError = acme::store::GetHttp01ChallengeKeyError; -pub trait BoxedManager: Manager + Send + Sync + Clone {} +#[mockall::automock( + type SyncDomainFuture=future::Ready>; +)] +pub trait Manager { + type SyncDomainFuture<'mgr>: future::Future> + + Send + + Unpin + + 'mgr + where + Self: 'mgr; + + fn sync_domain(&self, domain: domain::Name) -> Self::SyncDomainFuture<'_>; + + fn get_http01_challenge_key(&self, token: &str) -> Result; +} + +pub trait BoxedManager: Manager + Send + Sync + Clone + 'static {} struct ManagerImpl where @@ -54,4 +69,157 @@ where impl BoxedManager for sync::Arc> where Store: acme::store::BoxedStore {} -impl Manager for sync::Arc> where Store: acme::store::BoxedStore {} +impl Manager for sync::Arc> +where + Store: acme::store::BoxedStore, +{ + type SyncDomainFuture<'mgr> = pin::Pin> + Send + 'mgr>> + where Self: 'mgr; + + fn sync_domain(&self, domain: domain::Name) -> Self::SyncDomainFuture<'_> { + Box::pin(async move { + let mut builder = acme2::OrderBuilder::new(self.account.clone()); + builder.add_dns_identifier(domain.as_str().to_string()); + let order = builder.build().await.map_unexpected()?; + + let authorizations = order.authorizations().await.map_unexpected()?; + + for auth in authorizations { + let challenge = auth + .get_challenge("http-01") + .ok_or(error::Unexpected::from("expected http-01 challenge"))?; + + let challenge_token = challenge + .token + .as_ref() + .ok_or(error::Unexpected::from("expected challenge to have token"))?; + + let challenge_key = challenge + .key_authorization() + .map_unexpected()? + .ok_or(error::Unexpected::from("expected challenge to have key"))?; + + self.store + .set_http01_challenge_key(challenge_token, &challenge_key) + .map_unexpected()?; + + // At this point the manager is prepared to serve the challenge key via the + // `get_http01_challenge_key` method. It is expected that there is some http + // server, with this domain pointing at it, which is prepared to serve that + // challenge token/key under the `/.well-known/` path. The `validate()` call below + // will instigate the acme server to make this check, and block until it succeeds. + + let challenge = challenge.validate().await.map_unexpected()?; + + // Poll the challenge every 5 seconds until it is in either the + // `valid` or `invalid` state. + let challenge = challenge + .wait_done(time::Duration::from_secs(5), 3) + .await + .map_unexpected()?; + + if challenge.status != acme2::ChallengeStatus::Valid { + return Err(error::Unexpected::from( + format!( + "expected challenge status to be valid, instead it was {:?}", + challenge.status + ) + .as_str(), + )); + } + + self.store + .del_http01_challenge_key(&challenge_token) + .map_unexpected()?; + + // Poll the authorization every 5 seconds until it is in either the + // `valid` or `invalid` state. + let authorization = auth + .wait_done(time::Duration::from_secs(5), 3) + .await + .map_unexpected()?; + + if authorization.status != acme2::AuthorizationStatus::Valid { + return Err(error::Unexpected::from( + format!( + "expected authorization status to be valid, instead it was {:?}", + authorization.status, + ) + .as_str(), + )); + } + } + + // Poll the order every 5 seconds until it is in either the `ready` or `invalid` state. + // Ready means that it is now ready for finalization (certificate creation). + let order = order + .wait_ready(time::Duration::from_secs(5), 3) + .await + .map_unexpected()?; + + if order.status != acme2::OrderStatus::Ready { + return Err(error::Unexpected::from( + format!( + "expected order status to be ready, instead it was {:?}", + order.status, + ) + .as_str(), + )); + } + + // Generate an RSA private key for the certificate. + let pkey = acme2::gen_rsa_private_key(4096).map_unexpected()?; + + // Create a certificate signing request for the order, and request + // the certificate. + let order = order + .finalize(acme2::Csr::Automatic(pkey)) + .await + .map_unexpected()?; + + // Poll the order every 5 seconds until it is in either the + // `valid` or `invalid` state. Valid means that the certificate + // has been provisioned, and is now ready for download. + let order = order + .wait_done(time::Duration::from_secs(5), 3) + .await + .map_unexpected()?; + + if order.status != acme2::OrderStatus::Valid { + return Err(error::Unexpected::from( + format!( + "expected order status to be valid, instead it was {:?}", + order.status, + ) + .as_str(), + )); + } + + // Download the certificate, and panic if it doesn't exist. + let cert = + order + .certificate() + .await + .map_unexpected()? + .ok_or(error::Unexpected::from( + "expected the order to return a certificate", + ))?; + + if cert.len() <= 1 { + return Err(error::Unexpected::from( + format!( + "expected more than one certificate to be returned, instead got {}", + cert.len(), + ) + .as_str(), + )); + } + + Ok(()) + }) + } + + fn get_http01_challenge_key(&self, token: &str) -> Result { + self.store.get_http01_challenge_key(token) + } +} diff --git a/src/domain/acme/store.rs b/src/domain/acme/store.rs index 3c1a5f9..3fa8641 100644 --- a/src/domain/acme/store.rs +++ b/src/domain/acme/store.rs @@ -5,6 +5,9 @@ use crate::domain::acme::{AccountKey, Certificate}; use crate::error; use crate::error::{MapUnexpected, ToUnexpected}; +use hex::ToHex; +use sha2::{Digest, Sha256}; + #[derive(thiserror::Error, Debug)] pub enum GetAccountKeyError { #[error("not found")] @@ -15,7 +18,7 @@ pub enum GetAccountKeyError { } #[derive(thiserror::Error, Debug)] -pub enum GetHttp01ChallengeError { +pub enum GetHttp01ChallengeKeyError { #[error("not found")] NotFound, @@ -37,9 +40,9 @@ pub trait Store { fn set_account_key(&self, k: &AccountKey) -> Result<(), error::Unexpected>; fn get_account_key(&self) -> Result; - fn set_http01_challenge(&self, token: &str, key: &str) -> Result<(), error::Unexpected>; - fn get_http01_challenge(&self, token: &str) -> Result; - fn del_http01_challenge(&self, token: &str) -> Result<(), error::Unexpected>; + fn set_http01_challenge_key(&self, token: &str, key: &str) -> Result<(), error::Unexpected>; + fn get_http01_challenge_key(&self, token: &str) -> Result; + fn del_http01_challenge_key(&self, token: &str) -> Result<(), error::Unexpected>; fn set_certificate( &self, @@ -51,7 +54,7 @@ pub trait Store { fn get_certificate(&self, domain: &str) -> Result, GetCertificateError>; } -pub trait BoxedStore: Store + Send + Sync + Clone {} +pub trait BoxedStore: Store + Send + Sync + Clone + 'static {} struct FSStore { dir_path: path::PathBuf, @@ -59,7 +62,7 @@ struct FSStore { pub fn new(dir_path: &path::Path) -> Result { fs::create_dir_all(dir_path).map_unexpected()?; - fs::create_dir_all(dir_path.join("http01_challenges")).map_unexpected()?; + fs::create_dir_all(dir_path.join("http01_challenge_keys")).map_unexpected()?; fs::create_dir_all(dir_path.join("certificates")).map_unexpected()?; Ok(sync::Arc::new(FSStore { @@ -72,8 +75,14 @@ impl FSStore { self.dir_path.join("account.key") } - fn http01_challenge_path(&self, token: &str) -> path::PathBuf { - self.dir_path.join("http01_challenges").join(token) + fn http01_challenge_key_path(&self, token: &str) -> path::PathBuf { + // hash it for safety + let mut h = Sha256::new(); + h.write_all(token.as_bytes()) + .expect("token successfully hashed"); + let n = h.finalize().encode_hex::(); + + self.dir_path.join("http01_challenge_keys").join(n) } fn certificate_path(&self, domain: &str) -> path::PathBuf { @@ -104,16 +113,16 @@ impl Store for sync::Arc { Ok(k) } - fn set_http01_challenge(&self, token: &str, key: &str) -> Result<(), error::Unexpected> { - let mut file = fs::File::create(self.http01_challenge_path(token)).map_unexpected()?; + fn set_http01_challenge_key(&self, token: &str, key: &str) -> Result<(), error::Unexpected> { + let mut file = fs::File::create(self.http01_challenge_key_path(token)).map_unexpected()?; file.write_all(key.as_bytes()).map_unexpected()?; Ok(()) } - fn get_http01_challenge(&self, token: &str) -> Result { + fn get_http01_challenge_key(&self, token: &str) -> Result { let mut file = - fs::File::open(self.http01_challenge_path(token)).map_err(|e| match e.kind() { - io::ErrorKind::NotFound => GetHttp01ChallengeError::NotFound, + fs::File::open(self.http01_challenge_key_path(token)).map_err(|e| match e.kind() { + io::ErrorKind::NotFound => GetHttp01ChallengeKeyError::NotFound, _ => e.to_unexpected().into(), })?; @@ -123,8 +132,8 @@ impl Store for sync::Arc { Ok(key) } - fn del_http01_challenge(&self, token: &str) -> Result<(), error::Unexpected> { - fs::remove_file(self.http01_challenge_path(token)).map_unexpected()?; + fn del_http01_challenge_key(&self, token: &str) -> Result<(), error::Unexpected> { + fs::remove_file(self.http01_challenge_key_path(token)).map_unexpected()?; Ok(()) } @@ -196,36 +205,36 @@ mod tests { } #[test] - fn http01_challenge() { - let tmp_dir = TempDir::new("domain_acme_store_http01_challenge").unwrap(); + fn http01_challenge_key() { + let tmp_dir = TempDir::new("domain_acme_store_http01_challenge_key").unwrap(); let store = new(tmp_dir.path()).expect("store created"); let token = "foo".to_string(); let key = "bar".to_string(); assert!(matches!( - store.get_http01_challenge(&token), - Err::(GetHttp01ChallengeError::NotFound) + store.get_http01_challenge_key(&token), + Err::(GetHttp01ChallengeKeyError::NotFound) )); store - .set_http01_challenge(&token, &key) + .set_http01_challenge_key(&token, &key) .expect("http01 challenge set"); assert_eq!( key, store - .get_http01_challenge(&token) + .get_http01_challenge_key(&token) .expect("retrieved http01 challenge"), ); store - .del_http01_challenge(&token) + .del_http01_challenge_key(&token) .expect("deleted http01 challenge"); assert!(matches!( - store.get_http01_challenge(&token), - Err::(GetHttp01ChallengeError::NotFound) + store.get_http01_challenge_key(&token), + Err::(GetHttp01ChallengeKeyError::NotFound) )); } } diff --git a/src/domain/manager.rs b/src/domain/manager.rs index 460ce1b..8b9515b 100644 --- a/src/domain/manager.rs +++ b/src/domain/manager.rs @@ -1,9 +1,8 @@ -use crate::domain::{self, checker, config}; +use crate::domain::{self, acme, checker, config}; use crate::error::{MapUnexpected, ToUnexpected}; use crate::{error, origin}; -use std::future; -use std::{pin, sync}; +use std::{future, pin, sync}; #[derive(thiserror::Error, Debug)] pub enum GetConfigError { @@ -114,6 +113,8 @@ impl From for SyncWithConfigError { } } +pub type GetAcmeHttp01ChallengeKeyError = acme::manager::GetHttp01ChallengeKeyError; + #[mockall::automock( type Origin=origin::MockOrigin; type SyncWithConfigFuture=future::Ready>; @@ -147,49 +148,61 @@ pub trait Manager { ) -> Self::SyncWithConfigFuture<'_>; fn sync_all_origins(&self) -> Result, error::Unexpected>; + + fn get_acme_http01_challenge_key( + &self, + token: &str, + ) -> Result; } pub trait BoxedManager: Manager + Send + Sync + Clone {} -struct ManagerImpl +struct ManagerImpl where OriginStore: origin::store::BoxedStore, DomainConfigStore: config::BoxedStore, + AcmeManager: acme::manager::BoxedManager, { origin_store: OriginStore, domain_config_store: DomainConfigStore, domain_checker: checker::DNSChecker, + acme_manager: Option, } -pub fn new( +pub fn new( origin_store: OriginStore, domain_config_store: DomainConfigStore, domain_checker: checker::DNSChecker, + acme_manager: Option, ) -> impl BoxedManager where OriginStore: origin::store::BoxedStore, DomainConfigStore: config::BoxedStore, + AcmeManager: acme::manager::BoxedManager, { sync::Arc::new(ManagerImpl { origin_store, domain_config_store, domain_checker, + acme_manager: acme_manager, }) } -impl BoxedManager - for sync::Arc> +impl BoxedManager + for sync::Arc> where OriginStore: origin::store::BoxedStore, DomainConfigStore: config::BoxedStore, + AcmeManager: acme::manager::BoxedManager, { } -impl Manager - for sync::Arc> +impl Manager + for sync::Arc> where OriginStore: origin::store::BoxedStore, DomainConfigStore: config::BoxedStore, + AcmeManager: acme::manager::BoxedManager, { type Origin<'mgr> = OriginStore::Origin<'mgr> where Self: 'mgr; @@ -231,6 +244,10 @@ where self.domain_config_store.set(&domain, &config)?; + if let Some(ref acme_manager) = self.acme_manager { + acme_manager.sync_domain(domain.clone()).await?; + } + Ok(()) }) } @@ -255,4 +272,15 @@ where None }))) } + + fn get_acme_http01_challenge_key( + &self, + token: &str, + ) -> Result { + if let Some(ref acme_manager) = self.acme_manager { + return acme_manager.get_http01_challenge_key(token); + } + + Err(GetAcmeHttp01ChallengeKeyError::NotFound) + } } diff --git a/src/main.rs b/src/main.rs index 5fad4ec..2c2ce52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,3 +1,5 @@ +#![feature(result_option_inspect)] + use clap::Parser; use futures::stream::StreamExt; use signal_hook_tokio::Signals; @@ -10,17 +12,25 @@ use std::path; use std::str::FromStr; use std::sync; +use domiply::domain::acme::manager::Manager as AcmeManager; use domiply::domain::manager::Manager; #[derive(Parser, Debug)] #[command(version)] #[command(about = "A domiply to another dimension")] struct Cli { + #[arg(long, required = true, env = "DOMIPLY_HTTP_DOMAIN")] + http_domain: String, + #[arg(long, default_value_t = SocketAddr::from_str("[::]:3030").unwrap(), env = "DOMIPLY_HTTP_LISTEN_ADDR")] http_listen_addr: SocketAddr, - #[arg(long, required = true, env = "DOMIPLY_HTTP_DOMAIN")] - http_domain: String, + #[arg( + long, + help = "E.g. '[::]:443', if given then SSL certs will automatically be retrieved for all domains using LetsEncrypt", + env = "DOMIPLY_HTTPS_LISTEN_ADDR" + )] + https_listen_addr: Option, #[arg(long, required = true, env = "DOMIPLY_PASSPHRASE")] passphrase: String, @@ -36,6 +46,12 @@ struct Cli { #[arg(long, required = true, env = "DOMIPLY_DOMAIN_CONFIG_STORE_DIR_PATH")] domain_config_store_dir_path: path::PathBuf, + + #[arg(long, required = true, env = "DOMIPLY_DOMAIN_ACME_STORE_DIR_PATH")] + domain_acme_store_dir_path: path::PathBuf, + + #[arg(long, required = true, env = "DOMIPLY_DOMAIN_ACME_CONTACT_EMAIL")] + domain_acme_contact_email: String, } fn main() { @@ -81,7 +97,29 @@ fn main() { let domain_config_store = domiply::domain::config::new(&config.domain_config_store_dir_path) .expect("domain config store initialized"); - let manager = domiply::domain::manager::new(origin_store, domain_config_store, domain_checker); + let domain_acme_manager = config.https_listen_addr.and_then(|_addr| { + let domain_acme_store = + domiply::domain::acme::store::new(&config.domain_acme_store_dir_path) + .expect("domain acme store initialized"); + + let domain_acme_manager = tokio_runtime.block_on(async { + domiply::domain::acme::manager::new( + domain_acme_store, + &config.domain_acme_contact_email, + ) + .await + .expect("domain acme manager initialized") + }); + + Some(domain_acme_manager) + }); + + let manager = domiply::domain::manager::new( + origin_store, + domain_config_store, + domain_checker, + domain_acme_manager.clone(), + ); let origin_syncer_handler = { let manager = manager.clone(); @@ -137,10 +175,12 @@ fn main() { }); let server_handler = { + let http_domain = config.http_domain.clone(); + tokio_runtime.spawn(async move { let addr = config.http_listen_addr; - println!("Listening on http://{}:{}", config.http_domain, addr.port()); + println!("Listening on http://{}:{}", http_domain, addr.port()); let server = hyper::Server::bind(&addr).serve(make_service); let graceful = server.with_graceful_shutdown(async { @@ -153,6 +193,22 @@ fn main() { }) }; + // if there's an acme manager then it means that https is enabled, and we should ensure that + // the http domain for domiply itself has a valid certificate. + if let Some(domain_acme_manager) = domain_acme_manager { + let domain = domiply::domain::Name::from_str(&config.http_domain) + .expect("--http-domain parses as a domain"); + + tokio_runtime.spawn(async move { + _ = domain_acme_manager + .sync_domain(domain.clone()) + .await + .inspect_err(|err| { + println!("Error while getting cert for {}: {err}", domain.as_str()) + }); + }); + } + tokio_runtime .block_on(async { futures::try_join!(origin_syncer_handler, server_handler) }) .unwrap(); diff --git a/src/service.rs b/src/service.rs index d528268..5827279 100644 --- a/src/service.rs +++ b/src/service.rs @@ -341,6 +341,21 @@ where return svc.render(200, path, ()); } + if method == Method::GET && path.starts_with("/.well-known/") { + let token = path.trim_start_matches("/.well-known/"); + + if let Ok(key) = svc.domain_manager.get_acme_http01_challenge_key(token) { + let body: hyper::Body = key.into(); + return match Response::builder().status(200).body(body) { + Ok(res) => Ok(res), + Err(err) => Err(format!( + "failed to write acme http-01 challenge key: {}", + err + )), + }; + } + } + match (method, path) { (&Method::GET, "/") | (&Method::GET, "/index.html") => svc.render_page("/index.html", ()), (&Method::GET, "/domain.html") => {