From 0f42327a57ea4b9e409cf53b5091458744240315 Mon Sep 17 00:00:00 2001 From: Brian Picciano Date: Wed, 17 May 2023 15:47:40 +0200 Subject: [PATCH] Implemented acme store, started on manager --- Cargo.lock | 127 +++++++++++++++++++- Cargo.toml | 2 + flake.nix | 16 ++- src/domain.rs | 1 + src/domain/acme.rs | 5 + src/domain/acme/manager.rs | 57 +++++++++ src/domain/acme/store.rs | 231 +++++++++++++++++++++++++++++++++++++ src/error.rs | 6 + src/lib.rs | 2 + src/origin/store/git.rs | 2 +- 10 files changed, 441 insertions(+), 8 deletions(-) create mode 100644 src/domain/acme.rs create mode 100644 src/domain/acme/manager.rs create mode 100644 src/domain/acme/store.rs diff --git a/Cargo.lock b/Cargo.lock index 633152a..0cdca0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,24 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "acme2" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "453e534d4f46dcdddd7aa8619e9a664e153f34383d14710db0b0d76c2964db89" +dependencies = [ + "base64 0.13.1", + "hyper", + "openssl", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "tracing-futures", +] + [[package]] name = "adler" version = "1.0.2" @@ -107,6 +125,12 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d468802bab17cbc0cc575e9b053f41e72aa36bfa6b7f55e3529ffa43161b97fa" +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.0" @@ -360,6 +384,7 @@ dependencies = [ name = "domiply" version = "0.1.0" dependencies = [ + "acme2", "clap", "futures", "gix", @@ -369,6 +394,7 @@ dependencies = [ "hyper", "mime_guess", "mockall", + "openssl", "rust-embed", "serde", "serde_json", @@ -495,6 +521,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "form_urlencoded" version = "1.1.0" @@ -1147,7 +1188,7 @@ version = "0.31.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f01c2bf7b989c679695ef635fc7d9e80072e08101be4b53193c8e8b649900102" dependencies = [ - "base64", + "base64 0.21.0", "bstr", "gix-command", "gix-credentials", @@ -1768,6 +1809,44 @@ version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7e5500299e16ebb147ae15a00a942af264cf3688f47923b8fc2cd5858f23ad3" +[[package]] +name = "openssl" +version = "0.10.52" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01b8574602df80f7b85fdfc5392fa884a4e3b3f4f35402c070ab34c3d3f78d56" +dependencies = [ + "bitflags 1.3.2", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + +[[package]] +name = "openssl-sys" +version = "0.9.87" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e17f59264b2809d77ae94f0e1ebabc434773f370d6ca667bd223ea10e06cc7e" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "parking_lot" version = "0.12.1" @@ -1841,6 +1920,26 @@ dependencies = [ "sha2", ] +[[package]] +name = "pin-project" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c95a7476719eab1e366eaf73d0260af3021184f18177925b07f54b30089ceead" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39407670928234ebc5e6e580247dd567ad73a3578460c5990f9503df207e8f07" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.15", +] + [[package]] name = "pin-project-lite" version = "0.2.9" @@ -1853,6 +1952,12 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26072860ba924cbfa98ea39c8c19b4dd6a4a25423dbdf219c1eca91aa0cf6964" + [[package]] name = "ppv-lite86" version = "0.2.17" @@ -2078,7 +2183,7 @@ version = "0.11.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13293b639a097af28fc8a90f22add145a9c954e49d77da06263d58cf44d5fb91" dependencies = [ - "base64", + "base64 0.21.0", "bytes", "encoding_rs", "futures-core", @@ -2203,7 +2308,7 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d194b56d58803a43635bdc398cd17e383d6f71f9182b9a192c127ca42494a59b" dependencies = [ - "base64", + "base64 0.21.0", ] [[package]] @@ -2579,6 +2684,16 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + [[package]] name = "trust-dns-client" version = "0.22.0" @@ -2730,6 +2845,12 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.9.4" diff --git a/Cargo.toml b/Cargo.toml index 7ec4574..832b58b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,3 +32,5 @@ hyper = { version = "0.14.26", features = [ "server" ]} http = "0.2.9" serde_urlencoded = "0.7.1" tokio-util = "0.7.8" +acme2 = "0.5.1" +openssl = "0.10.52" diff --git a/flake.nix b/flake.nix index f969c62..c8be193 100644 --- a/flake.nix +++ b/flake.nix @@ -23,24 +23,32 @@ rustc = toolchain; }; + opensslEnv = { + OPENSSL_STATIC = "1"; + OPENSSL_LIB_DIR = "${pkgs.pkgsStatic.openssl.out}/lib"; + OPENSSL_INCLUDE_DIR = "${pkgs.pkgsStatic.openssl.dev}/include"; + }; + in { - defaultPackage = naersk-lib.buildPackage { + defaultPackage = naersk-lib.buildPackage ({ src = ./.; doCheck = false; nativeBuildInputs = [ pkgs.pkgsStatic.stdenv.cc ]; CARGO_BUILD_TARGET = "x86_64-unknown-linux-musl"; CARGO_BUILD_RUSTFLAGS = "-C target-feature=+crt-static"; - }; + } // opensslEnv); - devShell = pkgs.mkShell { + devShell = pkgs.mkShell ({ nativeBuildInputs = [ + pkgs.stdenv.cc + pkgs.openssl toolchain ]; shellHook = '' source $(pwd)/.env.dev export CARGO_HOME=$(pwd)/.cargo ''; - }; + } // opensslEnv); }); } diff --git a/src/domain.rs b/src/domain.rs index 15848e4..10f2373 100644 --- a/src/domain.rs +++ b/src/domain.rs @@ -1,3 +1,4 @@ +pub mod acme; pub mod checker; pub mod config; pub mod manager; diff --git a/src/domain/acme.rs b/src/domain/acme.rs new file mode 100644 index 0000000..e85d37b --- /dev/null +++ b/src/domain/acme.rs @@ -0,0 +1,5 @@ +pub mod manager; +pub mod store; + +pub type AccountKey = openssl::pkey::PKey; +pub type Certificate = openssl::x509::X509; diff --git a/src/domain/acme/manager.rs b/src/domain/acme/manager.rs new file mode 100644 index 0000000..d94c916 --- /dev/null +++ b/src/domain/acme/manager.rs @@ -0,0 +1,57 @@ +use std::sync; + +use crate::domain::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 trait BoxedManager: Manager + Send + Sync + Clone {} + +struct ManagerImpl +where + Store: acme::store::BoxedStore, +{ + store: Store, + account: sync::Arc, +} + +pub async fn new( + store: Store, + contact_email: &str, +) -> Result +where + Store: acme::store::BoxedStore, +{ + let dir = acme2::DirectoryBuilder::new(LETS_ENCRYPT_URL.to_string()) + .build() + .await + .map_unexpected()?; + + let mut builder = acme2::AccountBuilder::new(dir); + builder.contact(vec![contact_email.to_string()]); + builder.terms_of_service_agreed(true); + + match store.get_account_key() { + Ok(account_key) => { + builder.private_key(account_key); + } + Err(acme::store::GetAccountKeyError::NotFound) => (), + Err(acme::store::GetAccountKeyError::Unexpected(err)) => return Err(err.to_unexpected()), + } + + let account = builder.build().await.map_unexpected()?; + + store + .set_account_key(&account.private_key()) + .map_unexpected()?; + + Ok(sync::Arc::new(ManagerImpl { store, account })) +} + +impl BoxedManager for sync::Arc> where Store: acme::store::BoxedStore {} + +impl Manager for sync::Arc> where Store: acme::store::BoxedStore {} diff --git a/src/domain/acme/store.rs b/src/domain/acme/store.rs new file mode 100644 index 0000000..3c1a5f9 --- /dev/null +++ b/src/domain/acme/store.rs @@ -0,0 +1,231 @@ +use std::io::{Read, Write}; +use std::{fs, io, path, sync}; + +use crate::domain::acme::{AccountKey, Certificate}; +use crate::error; +use crate::error::{MapUnexpected, ToUnexpected}; + +#[derive(thiserror::Error, Debug)] +pub enum GetAccountKeyError { + #[error("not found")] + NotFound, + + #[error(transparent)] + Unexpected(#[from] error::Unexpected), +} + +#[derive(thiserror::Error, Debug)] +pub enum GetHttp01ChallengeError { + #[error("not found")] + NotFound, + + #[error(transparent)] + Unexpected(#[from] error::Unexpected), +} + +#[derive(thiserror::Error, Debug)] +pub enum GetCertificateError { + #[error("not found")] + NotFound, + + #[error(transparent)] + Unexpected(#[from] error::Unexpected), +} + +#[mockall::automock] +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_certificate( + &self, + domain: &str, + cert: Vec, + ) -> Result<(), error::Unexpected>; + + /// Returned vec is guaranteed to have len > 0 + fn get_certificate(&self, domain: &str) -> Result, GetCertificateError>; +} + +pub trait BoxedStore: Store + Send + Sync + Clone {} + +struct FSStore { + dir_path: path::PathBuf, +} + +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("certificates")).map_unexpected()?; + + Ok(sync::Arc::new(FSStore { + dir_path: dir_path.into(), + })) +} + +impl FSStore { + fn account_key_path(&self) -> path::PathBuf { + self.dir_path.join("account.key") + } + + fn http01_challenge_path(&self, token: &str) -> path::PathBuf { + self.dir_path.join("http01_challenges").join(token) + } + + fn certificate_path(&self, domain: &str) -> path::PathBuf { + self.dir_path.join("certificates").join(domain) + } +} + +impl BoxedStore for sync::Arc {} + +impl Store for sync::Arc { + fn set_account_key(&self, k: &AccountKey) -> Result<(), error::Unexpected> { + let mut file = fs::File::create(self.account_key_path()).map_unexpected()?; + let pem = k.private_key_to_pem_pkcs8().map_unexpected()?; + file.write_all(&pem).map_unexpected()?; + Ok(()) + } + + fn get_account_key(&self) -> Result { + let mut file = fs::File::open(self.account_key_path()).map_err(|e| match e.kind() { + io::ErrorKind::NotFound => GetAccountKeyError::NotFound, + _ => e.to_unexpected().into(), + })?; + + let mut pem = Vec::::new(); + file.read_to_end(&mut pem).map_unexpected()?; + + let k = AccountKey::private_key_from_pem(&pem).map_unexpected()?; + 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()?; + file.write_all(key.as_bytes()).map_unexpected()?; + Ok(()) + } + + fn get_http01_challenge(&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, + _ => e.to_unexpected().into(), + })?; + + let mut key = String::new(); + file.read_to_string(&mut key).map_unexpected()?; + + Ok(key) + } + + fn del_http01_challenge(&self, token: &str) -> Result<(), error::Unexpected> { + fs::remove_file(self.http01_challenge_path(token)).map_unexpected()?; + Ok(()) + } + + fn set_certificate( + &self, + domain: &str, + cert: Vec, + ) -> Result<(), error::Unexpected> { + let cert: Vec = cert + .into_iter() + .map(|cert| { + let cert_pem = cert.to_pem().map_unexpected()?; + let cert_pem = String::from_utf8(cert_pem).map_unexpected()?; + Ok::(cert_pem) + }) + .try_collect()?; + + let cert_file = fs::File::create(self.certificate_path(domain)).map_unexpected()?; + + serde_json::to_writer(cert_file, &cert).map_unexpected()?; + + Ok(()) + } + + fn get_certificate(&self, domain: &str) -> Result, GetCertificateError> { + let file = fs::File::open(self.certificate_path(domain)).map_err(|e| match e.kind() { + io::ErrorKind::NotFound => GetCertificateError::NotFound, + _ => e.to_unexpected().into(), + })?; + + let cert: Vec = serde_json::from_reader(file).map_unexpected()?; + + let cert: Vec = cert + .into_iter() + .map(|cert| openssl::x509::X509::from_pem(cert.as_bytes()).map_unexpected()) + .try_collect()?; + + Ok(cert) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempdir::TempDir; + + #[test] + fn account_key() { + let tmp_dir = TempDir::new("domain_acme_store_account_key").unwrap(); + let store = new(tmp_dir.path()).expect("store created"); + + assert!(matches!( + store.get_account_key(), + Err::(GetAccountKeyError::NotFound) + )); + + let k = acme2::gen_rsa_private_key(4096).expect("private key generated"); + + store.set_account_key(&k).expect("account private key set"); + + assert_eq!( + k.private_key_to_pem_pkcs8().unwrap(), + store + .get_account_key() + .expect("account private key retrieved") + .private_key_to_pem_pkcs8() + .unwrap() + ); + } + + #[test] + fn http01_challenge() { + let tmp_dir = TempDir::new("domain_acme_store_http01_challenge").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 + .set_http01_challenge(&token, &key) + .expect("http01 challenge set"); + + assert_eq!( + key, + store + .get_http01_challenge(&token) + .expect("retrieved http01 challenge"), + ); + + store + .del_http01_challenge(&token) + .expect("deleted http01 challenge"); + + assert!(matches!( + store.get_http01_challenge(&token), + Err::(GetHttp01ChallengeError::NotFound) + )); + } +} diff --git a/src/error.rs b/src/error.rs index 09e8ccd..57e9132 100644 --- a/src/error.rs +++ b/src/error.rs @@ -3,6 +3,12 @@ use std::fmt; use std::fmt::Write; #[derive(Debug, Clone)] +/// Unexpected is a String which implements the Error trait. It is intended to be used in +/// situations where the caller is being given an error they can't really handle, except to pass it +/// along or log it. +/// +/// The error is intended to also implement Send + Sync + Clone, such that it is easy to use in +/// async situations. pub struct Unexpected(String); impl fmt::Display for Unexpected { diff --git a/src/lib.rs b/src/lib.rs index 97d4b34..7cf0674 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,5 @@ +#![feature(iterator_try_collect)] + pub mod domain; pub mod error; pub mod origin; diff --git a/src/origin/store/git.rs b/src/origin/store/git.rs index 5fcabc8..342d737 100644 --- a/src/origin/store/git.rs +++ b/src/origin/store/git.rs @@ -315,7 +315,7 @@ mod tests { let descr = origin::Descr::Git { url: curr_dir.clone(), - branch_name: String::from("master"), + branch_name: String::from("main"), }; let other_descr = origin::Descr::Git {