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
6pub(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
22/// Days before expiry to trigger renewal.
23const RENEWAL_THRESHOLD_DAYS: i64 = 30;
24
25/// Manages ACME HTTP-01 challenges and certificate loading.
26#[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    /// Semaphore ensuring only one ACME order is in-flight at a time.
33    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    /// Restrict key material left world-readable by earlier versions (#186).
48    ///
49    /// Call once at startup. Kept out of [`AcmeManager::new`] so constructing
50    /// a manager, which tests do freely, never touches the real `~/.orca`.
51    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    /// Create a manager with default cache directory (`~/.orca/certs/`).
68    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    /// Register a domain for certificate provisioning.
74    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    /// Store a challenge token and its authorization response.
81    pub async fn set_challenge(&self, token: String, authorization: String) {
82        self.challenges.write().await.insert(token, authorization);
83    }
84
85    /// Get the authorization response for an HTTP-01 challenge token.
86    pub async fn get_challenge_response(&self, token: &str) -> Option<String> {
87        self.challenges.read().await.get(token).cloned()
88    }
89
90    /// Remove a completed challenge.
91    pub async fn clear_challenge(&self, token: &str) {
92        self.challenges.write().await.remove(token);
93    }
94
95    /// Load certificates from cache. Returns `None` if missing or unparseable.
96    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    /// Returns `true` if certs are missing or expiring within 30 days.
118    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    /// Build a `TlsAcceptor` from cached certs for the given domain.
137    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    /// Provision a cert for a domain and add it to the dynamic resolver.
153    ///
154    /// If a valid cached cert exists, it's loaded instead of re-provisioning.
155    /// This is the hot-provisioning entry point called during `orca deploy`.
156    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        // Acquire the provision lock to serialize ACME orders.
166        // Concurrent orders to the same ACME provider can fail.
167        let _permit = self
168            .provision_lock
169            .acquire()
170            .await
171            .map_err(|e| anyhow::anyhow!("ACME provision lock closed: {e}"))?;
172
173        // Re-check after acquiring lock (another task may have provisioned it)
174        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        // Try cache first
183        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    /// Build a `CertifiedKey` from PEM bytes.
199    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    /// Build an `AcmeProvider` from this manager for cert provisioning.
224    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
239/// Where the ACME account credentials are cached.
240pub(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}