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 secure_key_material(&self) {
52 match provider::secure_existing_key_material(&self.cache_dir, &default_account_path()) {
53 Ok(0) => {}
54 Ok(n) => warn!(
55 tightened = n,
56 cache = %self.cache_dir.display(),
57 "restricted TLS key material that earlier orca versions left \
58 readable by other local users; consider rotating those keys"
59 ),
60 Err(e) => warn!(
61 cache = %self.cache_dir.display(),
62 "could not restrict TLS key material: {e}"
63 ),
64 }
65 }
66
67 pub fn with_default_cache(email: impl Into<String>) -> Self {
69 let cache_dir = default_orca_dir().join("certs");
70 Self::new(email, cache_dir)
71 }
72
73 pub async fn add_domain(&self, domain: impl Into<String>) {
75 let domain = domain.into();
76 info!(domain = %domain, "Registered domain for ACME");
77 self.domains.write().await.insert(domain);
78 }
79
80 pub async fn set_challenge(&self, token: String, authorization: String) {
82 self.challenges.write().await.insert(token, authorization);
83 }
84
85 pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
87 self.challenges.read().await.get(token).cloned()
88 }
89
90 pub async fn clear_challenge(&self, token: &str) {
92 self.challenges.write().await.remove(token);
93 }
94
95 pub fn load_cached_certs(
97 &self,
98 domain: &str,
99 ) -> Option<(
100 Vec<rustls::pki_types::CertificateDer<'static>>,
101 rustls::pki_types::PrivateKeyDer<'static>,
102 )> {
103 let cert_path = self.cert_path(domain);
104 let key_path = self.key_path(domain);
105 if !cert_path.exists() || !key_path.exists() {
106 return None;
107 }
108 match certs::load_pem_certs(&cert_path, &key_path) {
109 Ok(pair) => Some(pair),
110 Err(e) => {
111 warn!(domain, error = %e, "Failed to load cached certs");
112 None
113 }
114 }
115 }
116
117 pub fn needs_renewal(&self, domain: &str) -> bool {
119 let cert_path = self.cert_path(domain);
120 if !cert_path.exists() {
121 return true;
122 }
123 match certs::check_cert_expiry(&cert_path) {
124 Ok(days) if days >= RENEWAL_THRESHOLD_DAYS => false,
125 Ok(days) => {
126 info!(domain, days_remaining = days, "Certificate expiring soon");
127 true
128 }
129 Err(e) => {
130 warn!(domain, error = %e, "Cannot check cert expiry");
131 true
132 }
133 }
134 }
135
136 pub fn tls_acceptor_for(&self, domain: &str) -> anyhow::Result<Option<TlsAcceptor>> {
138 let Some((certs, key)) = self.load_cached_certs(domain) else {
139 return Ok(None);
140 };
141 if self.needs_renewal(domain) {
142 warn!(domain, "Cert expiring soon — will auto-renew");
143 }
144 let config = crate::tls::with_h2_alpn(
145 rustls::ServerConfig::builder()
146 .with_no_client_auth()
147 .with_single_cert(certs, key)?,
148 );
149 Ok(Some(TlsAcceptor::from(Arc::new(config))))
150 }
151
152 pub async fn ensure_cert_for_resolver(
157 &self,
158 domain: &str,
159 resolver: &DynCertResolver,
160 ) -> anyhow::Result<()> {
161 if resolver.has_cert(domain) && !self.needs_renewal(domain) {
162 return Ok(());
163 }
164
165 let _permit = self
168 .provision_lock
169 .acquire()
170 .await
171 .map_err(|e| anyhow::anyhow!("ACME provision lock closed: {e}"))?;
172
173 if resolver.has_cert(domain) && !self.needs_renewal(domain) {
175 return Ok(());
176 }
177
178 let provider = self.provider();
179 let cert_path = self.cert_path(domain);
180 let key_path = self.key_path(domain);
181
182 let (cert_pem, key_pem) =
184 if cert_path.exists() && key_path.exists() && !self.needs_renewal(domain) {
185 info!(domain, "Loading cached cert for hot provisioning");
186 (std::fs::read(&cert_path)?, std::fs::read(&key_path)?)
187 } else {
188 info!(domain, "Hot-provisioning TLS certificate");
189 provider.provision_cert(domain).await?
190 };
191
192 let certified_key = Self::build_certified_key(&cert_pem, &key_pem)?;
193 resolver.add_cert(domain, Arc::new(certified_key));
194 info!(domain, "Certificate ready (hot-provisioned)");
195 Ok(())
196 }
197
198 fn build_certified_key(
200 cert_pem: &[u8],
201 key_pem: &[u8],
202 ) -> anyhow::Result<rustls::sign::CertifiedKey> {
203 let certs: Vec<_> =
204 rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<Vec<_>, _>>()?;
205 let key = rustls_pemfile::private_key(&mut &key_pem[..])?
206 .ok_or_else(|| anyhow::anyhow!("no private key in PEM data"))?;
207 let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?;
208 Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
209 }
210
211 pub fn cert_path(&self, domain: &str) -> PathBuf {
212 self.cache_dir.join(format!("{domain}.cert.pem"))
213 }
214
215 pub fn key_path(&self, domain: &str) -> PathBuf {
216 self.cache_dir.join(format!("{domain}.key.pem"))
217 }
218
219 pub async fn domains(&self) -> Vec<String> {
220 self.domains.read().await.iter().cloned().collect()
221 }
222
223 pub fn provider(&self) -> AcmeProvider {
225 AcmeProvider::new(
226 self.acme_email.clone(),
227 self.cache_dir.clone(),
228 self.challenges.clone(),
229 )
230 }
231}
232
233pub(crate) fn default_orca_dir() -> PathBuf {
234 dirs::home_dir()
235 .unwrap_or_else(|| PathBuf::from("."))
236 .join(".orca")
237}
238
239pub(crate) fn default_account_path() -> PathBuf {
241 default_orca_dir().join("acme-account.json")
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247
248 #[tokio::test]
249 async fn test_challenge_lifecycle() {
250 let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
251 assert!(mgr.get_challenge_response("tok1").await.is_none());
252 mgr.set_challenge("tok1".into(), "auth1".into()).await;
253 assert_eq!(mgr.get_challenge_response("tok1").await.unwrap(), "auth1");
254 mgr.clear_challenge("tok1").await;
255 assert!(mgr.get_challenge_response("tok1").await.is_none());
256 }
257
258 #[tokio::test]
259 async fn test_domain_registration() {
260 let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
261 mgr.add_domain("example.com").await;
262 assert!(mgr.domains().await.contains(&"example.com".to_string()));
263 }
264
265 #[test]
266 fn test_cert_paths() {
267 let mgr = AcmeManager::new("test@example.com", "/tmp/certs");
268 assert_eq!(
269 mgr.cert_path("example.com"),
270 PathBuf::from("/tmp/certs/example.com.cert.pem")
271 );
272 assert_eq!(
273 mgr.key_path("example.com"),
274 PathBuf::from("/tmp/certs/example.com.key.pem")
275 );
276 }
277
278 #[test]
279 fn test_missing_certs_needs_renewal() {
280 let mgr = AcmeManager::new("test@example.com", "/tmp/nonexistent-certs");
281 assert!(mgr.needs_renewal("example.com"));
282 }
283}