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