1pub(crate) mod certs;
7mod provider;
8pub mod renewal;
9mod resolver;
10
11pub use provider::AcmeProvider;
12pub use resolver::DynCertResolver;
13
14use std::collections::{HashMap, HashSet};
15use std::path::PathBuf;
16use std::sync::Arc;
17
18use tokio::sync::RwLock;
19use tokio_rustls::TlsAcceptor;
20use tracing::{info, warn};
21
22const RENEWAL_THRESHOLD_DAYS: i64 = 30;
24
25#[derive(Clone)]
27pub struct AcmeManager {
28 pub acme_email: String,
29 pub cache_dir: PathBuf,
30 challenges: Arc<RwLock<HashMap<String, String>>>,
31 domains: Arc<RwLock<HashSet<String>>>,
32 provision_lock: Arc<tokio::sync::Semaphore>,
34}
35
36impl AcmeManager {
37 pub fn new(email: impl Into<String>, cache_dir: impl Into<PathBuf>) -> Self {
38 Self {
39 acme_email: email.into(),
40 cache_dir: cache_dir.into(),
41 challenges: Arc::new(RwLock::new(HashMap::new())),
42 domains: Arc::new(RwLock::new(HashSet::new())),
43 provision_lock: Arc::new(tokio::sync::Semaphore::new(1)),
44 }
45 }
46
47 pub fn with_default_cache(email: impl Into<String>) -> Self {
49 let cache_dir = default_orca_dir().join("certs");
50 Self::new(email, cache_dir)
51 }
52
53 pub async fn add_domain(&self, domain: impl Into<String>) {
55 let domain = domain.into();
56 info!(domain = %domain, "Registered domain for ACME");
57 self.domains.write().await.insert(domain);
58 }
59
60 pub async fn set_challenge(&self, token: String, authorization: String) {
62 self.challenges.write().await.insert(token, authorization);
63 }
64
65 pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
67 self.challenges.read().await.get(token).cloned()
68 }
69
70 pub async fn clear_challenge(&self, token: &str) {
72 self.challenges.write().await.remove(token);
73 }
74
75 pub fn load_cached_certs(
77 &self,
78 domain: &str,
79 ) -> Option<(
80 Vec<rustls::pki_types::CertificateDer<'static>>,
81 rustls::pki_types::PrivateKeyDer<'static>,
82 )> {
83 let cert_path = self.cert_path(domain);
84 let key_path = self.key_path(domain);
85 if !cert_path.exists() || !key_path.exists() {
86 return None;
87 }
88 match certs::load_pem_certs(&cert_path, &key_path) {
89 Ok(pair) => Some(pair),
90 Err(e) => {
91 warn!(domain, error = %e, "Failed to load cached certs");
92 None
93 }
94 }
95 }
96
97 pub fn needs_renewal(&self, domain: &str) -> bool {
99 let cert_path = self.cert_path(domain);
100 if !cert_path.exists() {
101 return true;
102 }
103 match certs::check_cert_expiry(&cert_path) {
104 Ok(days) if days >= RENEWAL_THRESHOLD_DAYS => false,
105 Ok(days) => {
106 info!(domain, days_remaining = days, "Certificate expiring soon");
107 true
108 }
109 Err(e) => {
110 warn!(domain, error = %e, "Cannot check cert expiry");
111 true
112 }
113 }
114 }
115
116 pub fn tls_acceptor_for(&self, domain: &str) -> anyhow::Result<Option<TlsAcceptor>> {
118 let Some((certs, key)) = self.load_cached_certs(domain) else {
119 return Ok(None);
120 };
121 if self.needs_renewal(domain) {
122 warn!(domain, "Cert expiring soon — will auto-renew");
123 }
124 let config = rustls::ServerConfig::builder()
125 .with_no_client_auth()
126 .with_single_cert(certs, key)?;
127 Ok(Some(TlsAcceptor::from(Arc::new(config))))
128 }
129
130 pub async fn ensure_cert_for_resolver(
135 &self,
136 domain: &str,
137 resolver: &DynCertResolver,
138 ) -> anyhow::Result<()> {
139 if resolver.has_cert(domain) && !self.needs_renewal(domain) {
140 return Ok(());
141 }
142
143 let _permit = self
146 .provision_lock
147 .acquire()
148 .await
149 .map_err(|e| anyhow::anyhow!("ACME provision lock closed: {e}"))?;
150
151 if resolver.has_cert(domain) && !self.needs_renewal(domain) {
153 return Ok(());
154 }
155
156 let provider = self.provider();
157 let cert_path = self.cert_path(domain);
158 let key_path = self.key_path(domain);
159
160 let (cert_pem, key_pem) =
162 if cert_path.exists() && key_path.exists() && !self.needs_renewal(domain) {
163 info!(domain, "Loading cached cert for hot provisioning");
164 (std::fs::read(&cert_path)?, std::fs::read(&key_path)?)
165 } else {
166 info!(domain, "Hot-provisioning TLS certificate");
167 provider.provision_cert(domain).await?
168 };
169
170 let certified_key = Self::build_certified_key(&cert_pem, &key_pem)?;
171 resolver.add_cert(domain, Arc::new(certified_key));
172 info!(domain, "Certificate ready (hot-provisioned)");
173 Ok(())
174 }
175
176 fn build_certified_key(
178 cert_pem: &[u8],
179 key_pem: &[u8],
180 ) -> anyhow::Result<rustls::sign::CertifiedKey> {
181 let certs: Vec<_> =
182 rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<Vec<_>, _>>()?;
183 let key = rustls_pemfile::private_key(&mut &key_pem[..])?
184 .ok_or_else(|| anyhow::anyhow!("no private key in PEM data"))?;
185 let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?;
186 Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
187 }
188
189 pub fn cert_path(&self, domain: &str) -> PathBuf {
190 self.cache_dir.join(format!("{domain}.cert.pem"))
191 }
192
193 pub fn key_path(&self, domain: &str) -> PathBuf {
194 self.cache_dir.join(format!("{domain}.key.pem"))
195 }
196
197 pub async fn domains(&self) -> Vec<String> {
198 self.domains.read().await.iter().cloned().collect()
199 }
200
201 pub fn provider(&self) -> AcmeProvider {
203 AcmeProvider::new(
204 self.acme_email.clone(),
205 self.cache_dir.clone(),
206 self.challenges.clone(),
207 )
208 }
209}
210
211pub(crate) fn default_orca_dir() -> PathBuf {
212 dirs::home_dir()
213 .unwrap_or_else(|| PathBuf::from("."))
214 .join(".orca")
215}
216
217#[cfg(test)]
218mod tests {
219 use super::*;
220
221 #[tokio::test]
222 async fn test_challenge_lifecycle() {
223 let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
224 assert!(mgr.get_challenge_response("tok1").await.is_none());
225 mgr.set_challenge("tok1".into(), "auth1".into()).await;
226 assert_eq!(mgr.get_challenge_response("tok1").await.unwrap(), "auth1");
227 mgr.clear_challenge("tok1").await;
228 assert!(mgr.get_challenge_response("tok1").await.is_none());
229 }
230
231 #[tokio::test]
232 async fn test_domain_registration() {
233 let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
234 mgr.add_domain("example.com").await;
235 assert!(mgr.domains().await.contains(&"example.com".to_string()));
236 }
237
238 #[test]
239 fn test_cert_paths() {
240 let mgr = AcmeManager::new("test@example.com", "/tmp/certs");
241 assert_eq!(
242 mgr.cert_path("example.com"),
243 PathBuf::from("/tmp/certs/example.com.cert.pem")
244 );
245 assert_eq!(
246 mgr.key_path("example.com"),
247 PathBuf::from("/tmp/certs/example.com.key.pem")
248 );
249 }
250
251 #[test]
252 fn test_missing_certs_needs_renewal() {
253 let mgr = AcmeManager::new("test@example.com", "/tmp/nonexistent-certs");
254 assert!(mgr.needs_renewal("example.com"));
255 }
256}