Skip to main content

orca_proxy/acme/
mod.rs

1//! ACME certificate management with automatic Let's Encrypt provisioning.
2//!
3//! Uses `instant-acme` for native Rust ACME (RFC 8555) support — no certbot
4//! dependency. Certificates are cached at `~/.orca/certs/`.
5
6mod backoff;
7pub(crate) mod certs;
8mod provider;
9pub mod renewal;
10mod resolver;
11
12pub use backoff::AcmeCooldown;
13pub use provider::AcmeProvider;
14pub use resolver::DynCertResolver;
15
16use std::collections::{HashMap, HashSet};
17use std::path::PathBuf;
18use std::sync::Arc;
19
20use tokio::sync::RwLock;
21use tokio_rustls::TlsAcceptor;
22use tracing::{info, warn};
23
24/// Days before expiry to trigger renewal.
25const RENEWAL_THRESHOLD_DAYS: i64 = 30;
26
27/// Manages ACME HTTP-01 challenges and certificate loading.
28#[derive(Clone)]
29pub struct AcmeManager {
30    pub acme_email: String,
31    pub cache_dir: PathBuf,
32    challenges: Arc<RwLock<HashMap<String, String>>>,
33    domains: Arc<RwLock<HashSet<String>>>,
34    /// Semaphore ensuring only one ACME order is in-flight at a time.
35    provision_lock: Arc<tokio::sync::Semaphore>,
36    /// Failure backoff shared by every caller (#188).
37    backoff: Arc<std::sync::Mutex<backoff::Backoff>>,
38}
39
40impl AcmeManager {
41    pub fn new(email: impl Into<String>, cache_dir: impl Into<PathBuf>) -> Self {
42        Self {
43            acme_email: email.into(),
44            cache_dir: cache_dir.into(),
45            challenges: Arc::new(RwLock::new(HashMap::new())),
46            domains: Arc::new(RwLock::new(HashSet::new())),
47            provision_lock: Arc::new(tokio::sync::Semaphore::new(1)),
48            backoff: Arc::default(),
49        }
50    }
51
52    /// Restrict key material left world-readable by earlier versions (#186).
53    ///
54    /// Call once at startup. Kept out of [`AcmeManager::new`] so constructing
55    /// a manager, which tests do freely, never touches the real `~/.orca`.
56    pub fn secure_key_material(&self) {
57        match provider::secure_existing_key_material(&self.cache_dir, &default_account_path()) {
58            Ok(0) => {}
59            Ok(n) => warn!(
60                tightened = n,
61                cache = %self.cache_dir.display(),
62                "restricted TLS key material that earlier orca versions left \
63                 readable by other local users; consider rotating those keys"
64            ),
65            Err(e) => warn!(
66                cache = %self.cache_dir.display(),
67                "could not restrict TLS key material: {e}"
68            ),
69        }
70    }
71
72    /// Create a manager with default cache directory (`~/.orca/certs/`).
73    pub fn with_default_cache(email: impl Into<String>) -> Self {
74        let cache_dir = default_orca_dir().join("certs");
75        Self::new(email, cache_dir)
76    }
77
78    /// Register a domain for certificate provisioning.
79    pub async fn add_domain(&self, domain: impl Into<String>) {
80        let domain = domain.into();
81        info!(domain = %domain, "Registered domain for ACME");
82        self.domains.write().await.insert(domain);
83    }
84
85    /// Store a challenge token and its authorization response.
86    pub async fn set_challenge(&self, token: String, authorization: String) {
87        self.challenges.write().await.insert(token, authorization);
88    }
89
90    /// Get the authorization response for an HTTP-01 challenge token.
91    pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
92        self.challenges.read().await.get(token).cloned()
93    }
94
95    /// Remove a completed challenge.
96    pub async fn clear_challenge(&self, token: &str) {
97        self.challenges.write().await.remove(token);
98    }
99
100    /// Load certificates from cache. Returns `None` if missing or unparseable.
101    pub fn load_cached_certs(
102        &self,
103        domain: &str,
104    ) -> Option<(
105        Vec<rustls::pki_types::CertificateDer<'static>>,
106        rustls::pki_types::PrivateKeyDer<'static>,
107    )> {
108        let cert_path = self.cert_path(domain);
109        let key_path = self.key_path(domain);
110        if !cert_path.exists() || !key_path.exists() {
111            return None;
112        }
113        match certs::load_pem_certs(&cert_path, &key_path) {
114            Ok(pair) => Some(pair),
115            Err(e) => {
116                warn!(domain, error = %e, "Failed to load cached certs");
117                None
118            }
119        }
120    }
121
122    /// Returns `true` if certs are missing or expiring within 30 days.
123    pub fn needs_renewal(&self, domain: &str) -> bool {
124        let cert_path = self.cert_path(domain);
125        if !cert_path.exists() {
126            return true;
127        }
128        match certs::check_cert_expiry(&cert_path) {
129            Ok(days) if days >= RENEWAL_THRESHOLD_DAYS => false,
130            Ok(days) => {
131                info!(domain, days_remaining = days, "Certificate expiring soon");
132                true
133            }
134            Err(e) => {
135                warn!(domain, error = %e, "Cannot check cert expiry");
136                true
137            }
138        }
139    }
140
141    /// Build a `TlsAcceptor` from cached certs for the given domain.
142    pub fn tls_acceptor_for(&self, domain: &str) -> anyhow::Result<Option<TlsAcceptor>> {
143        let Some((certs, key)) = self.load_cached_certs(domain) else {
144            return Ok(None);
145        };
146        if self.needs_renewal(domain) {
147            warn!(domain, "Cert expiring soon — will auto-renew");
148        }
149        let config = crate::tls::with_h2_alpn(
150            rustls::ServerConfig::builder()
151                .with_no_client_auth()
152                .with_single_cert(certs, key)?,
153        );
154        Ok(Some(TlsAcceptor::from(Arc::new(config))))
155    }
156
157    /// Provision a cert for a domain and add it to the dynamic resolver.
158    ///
159    /// If a valid cached cert exists, it's loaded instead of re-provisioning.
160    /// This is the hot-provisioning entry point called during `orca deploy`.
161    pub async fn ensure_cert_for_resolver(
162        &self,
163        domain: &str,
164        resolver: &DynCertResolver,
165    ) -> anyhow::Result<()> {
166        if resolver.has_cert(domain) && !self.needs_renewal(domain) {
167            return Ok(());
168        }
169
170        // Acquire the provision lock to serialize ACME orders.
171        // Concurrent orders to the same ACME provider can fail.
172        let _permit = self
173            .provision_lock
174            .acquire()
175            .await
176            .map_err(|e| anyhow::anyhow!("ACME provision lock closed: {e}"))?;
177
178        // Re-check after acquiring lock (another task may have provisioned it)
179        if resolver.has_cert(domain) && !self.needs_renewal(domain) {
180            return Ok(());
181        }
182
183        let provider = self.provider();
184        let cert_path = self.cert_path(domain);
185        let key_path = self.key_path(domain);
186
187        // Try cache first
188        let (cert_pem, key_pem) =
189            if cert_path.exists() && key_path.exists() && !self.needs_renewal(domain) {
190                info!(domain, "Loading cached cert for hot provisioning");
191                (std::fs::read(&cert_path)?, std::fs::read(&key_path)?)
192            } else {
193                // A domain that failed recently gets no new order yet: every
194                // caller shares this budget (#188).
195                self.backoff
196                    .lock()
197                    .unwrap_or_else(|e| e.into_inner())
198                    .check(domain)?;
199                info!(domain, "Hot-provisioning TLS certificate");
200                match provider.provision_cert(domain).await {
201                    Ok(pair) => {
202                        self.backoff
203                            .lock()
204                            .unwrap_or_else(|e| e.into_inner())
205                            .succeeded(domain);
206                        pair
207                    }
208                    Err(e) => {
209                        self.backoff
210                            .lock()
211                            .unwrap_or_else(|e| e.into_inner())
212                            .failed(domain, &format!("{e:#}"));
213                        return Err(e);
214                    }
215                }
216            };
217
218        let certified_key = Self::build_certified_key(&cert_pem, &key_pem)?;
219        resolver.add_cert(domain, Arc::new(certified_key));
220        info!(domain, "Certificate ready (hot-provisioned)");
221        Ok(())
222    }
223
224    /// Build a `CertifiedKey` from PEM bytes.
225    fn build_certified_key(
226        cert_pem: &[u8],
227        key_pem: &[u8],
228    ) -> anyhow::Result<rustls::sign::CertifiedKey> {
229        let certs: Vec<_> =
230            rustls_pemfile::certs(&mut &cert_pem[..]).collect::<Result<Vec<_>, _>>()?;
231        let key = rustls_pemfile::private_key(&mut &key_pem[..])?
232            .ok_or_else(|| anyhow::anyhow!("no private key in PEM data"))?;
233        let signing_key = rustls::crypto::aws_lc_rs::sign::any_supported_type(&key)?;
234        Ok(rustls::sign::CertifiedKey::new(certs, signing_key))
235    }
236
237    pub fn cert_path(&self, domain: &str) -> PathBuf {
238        self.cache_dir.join(format!("{domain}.cert.pem"))
239    }
240
241    pub fn key_path(&self, domain: &str) -> PathBuf {
242        self.cache_dir.join(format!("{domain}.key.pem"))
243    }
244
245    pub async fn domains(&self) -> Vec<String> {
246        self.domains.read().await.iter().cloned().collect()
247    }
248
249    /// Build an `AcmeProvider` from this manager for cert provisioning.
250    pub fn provider(&self) -> AcmeProvider {
251        AcmeProvider::new(
252            self.acme_email.clone(),
253            self.cache_dir.clone(),
254            self.challenges.clone(),
255        )
256    }
257}
258
259pub(crate) fn default_orca_dir() -> PathBuf {
260    dirs::home_dir()
261        .unwrap_or_else(|| PathBuf::from("."))
262        .join(".orca")
263}
264
265/// Where the ACME account credentials are cached.
266pub(crate) fn default_account_path() -> PathBuf {
267    default_orca_dir().join("acme-account.json")
268}
269
270#[cfg(test)]
271mod tests {
272    use super::*;
273
274    #[tokio::test]
275    async fn test_challenge_lifecycle() {
276        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
277        assert!(mgr.get_challenge_response("tok1").await.is_none());
278        mgr.set_challenge("tok1".into(), "auth1".into()).await;
279        assert_eq!(mgr.get_challenge_response("tok1").await.unwrap(), "auth1");
280        mgr.clear_challenge("tok1").await;
281        assert!(mgr.get_challenge_response("tok1").await.is_none());
282    }
283
284    #[tokio::test]
285    async fn test_domain_registration() {
286        let mgr = AcmeManager::new("test@example.com", "/tmp/orca-test-certs");
287        mgr.add_domain("example.com").await;
288        assert!(mgr.domains().await.contains(&"example.com".to_string()));
289    }
290
291    #[test]
292    fn test_cert_paths() {
293        let mgr = AcmeManager::new("test@example.com", "/tmp/certs");
294        assert_eq!(
295            mgr.cert_path("example.com"),
296            PathBuf::from("/tmp/certs/example.com.cert.pem")
297        );
298        assert_eq!(
299            mgr.key_path("example.com"),
300            PathBuf::from("/tmp/certs/example.com.key.pem")
301        );
302    }
303
304    #[test]
305    fn test_missing_certs_needs_renewal() {
306        let mgr = AcmeManager::new("test@example.com", "/tmp/nonexistent-certs");
307        assert!(mgr.needs_renewal("example.com"));
308    }
309}
310
311#[cfg(test)]
312#[path = "backoff_wiring_tests.rs"]
313mod backoff_wiring_tests;